From 1f29aae2beee50dc9d630a37f587c3fe0dfdea78 Mon Sep 17 00:00:00 2001 From: "cvat-bot[bot]" <147643061+cvat-bot[bot]@users.noreply.github.com> Date: Fri, 20 Dec 2024 08:27:20 +0000 Subject: [PATCH 01/31] Update develop after v2.24.0 --- cvat-cli/requirements/base.txt | 2 +- cvat-cli/src/cvat_cli/version.py | 2 +- cvat-sdk/gen/generate.sh | 2 +- cvat/__init__.py | 2 +- cvat/schema.yml | 2 +- docker-compose.yml | 20 ++++++++++---------- helm-chart/values.yaml | 4 ++-- 7 files changed, 17 insertions(+), 17 deletions(-) diff --git a/cvat-cli/requirements/base.txt b/cvat-cli/requirements/base.txt index 664017edebe3..94b064e0ace5 100644 --- a/cvat-cli/requirements/base.txt +++ b/cvat-cli/requirements/base.txt @@ -1,3 +1,3 @@ -cvat-sdk~=2.24.0 +cvat-sdk~=2.24.1 Pillow>=10.3.0 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability diff --git a/cvat-cli/src/cvat_cli/version.py b/cvat-cli/src/cvat_cli/version.py index 203e6c4bc9b2..c176a6b233ec 100644 --- a/cvat-cli/src/cvat_cli/version.py +++ b/cvat-cli/src/cvat_cli/version.py @@ -1 +1 @@ -VERSION = "2.24.0" +VERSION = "2.24.1" diff --git a/cvat-sdk/gen/generate.sh b/cvat-sdk/gen/generate.sh index f4d78e868601..939ac9d65b44 100755 --- a/cvat-sdk/gen/generate.sh +++ b/cvat-sdk/gen/generate.sh @@ -8,7 +8,7 @@ set -e GENERATOR_VERSION="v6.0.1" -VERSION="2.24.0" +VERSION="2.24.1" LIB_NAME="cvat_sdk" LAYER1_LIB_NAME="${LIB_NAME}/api_client" DST_DIR="$(cd "$(dirname -- "$0")/.." && pwd)" diff --git a/cvat/__init__.py b/cvat/__init__.py index 7586ced41d72..cd11fa1758cc 100644 --- a/cvat/__init__.py +++ b/cvat/__init__.py @@ -4,6 +4,6 @@ from cvat.utils.version import get_version -VERSION = (2, 24, 0, "final", 0) +VERSION = (2, 24, 1, "alpha", 0) __version__ = get_version(VERSION) diff --git a/cvat/schema.yml b/cvat/schema.yml index 45f95346c769..8af068ecc8b2 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: CVAT REST API - version: 2.24.0 + version: 2.24.1 description: REST API for Computer Vision Annotation Tool (CVAT) termsOfService: https://www.google.com/policies/terms/ contact: diff --git a/docker-compose.yml b/docker-compose.yml index b956bc6fcca5..c13cb5bab74f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,7 +81,7 @@ services: cvat_server: container_name: cvat_server - image: cvat/server:${CVAT_VERSION:-v2.24.0} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: <<: *backend-deps @@ -115,7 +115,7 @@ services: cvat_utils: container_name: cvat_utils - image: cvat/server:${CVAT_VERSION:-v2.24.0} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: *backend-deps environment: @@ -132,7 +132,7 @@ services: cvat_worker_import: container_name: cvat_worker_import - image: cvat/server:${CVAT_VERSION:-v2.24.0} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: *backend-deps environment: @@ -148,7 +148,7 @@ services: cvat_worker_export: container_name: cvat_worker_export - image: cvat/server:${CVAT_VERSION:-v2.24.0} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: *backend-deps environment: @@ -164,7 +164,7 @@ services: cvat_worker_annotation: container_name: cvat_worker_annotation - image: cvat/server:${CVAT_VERSION:-v2.24.0} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: *backend-deps environment: @@ -180,7 +180,7 @@ services: cvat_worker_webhooks: container_name: cvat_worker_webhooks - image: cvat/server:${CVAT_VERSION:-v2.24.0} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: *backend-deps environment: @@ -196,7 +196,7 @@ services: cvat_worker_quality_reports: container_name: cvat_worker_quality_reports - image: cvat/server:${CVAT_VERSION:-v2.24.0} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: *backend-deps environment: @@ -212,7 +212,7 @@ services: cvat_worker_analytics_reports: container_name: cvat_worker_analytics_reports - image: cvat/server:${CVAT_VERSION:-v2.24.0} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: *backend-deps environment: @@ -228,7 +228,7 @@ services: cvat_worker_chunks: container_name: cvat_worker_chunks - image: cvat/server:${CVAT_VERSION:-v2.24.0} + image: cvat/server:${CVAT_VERSION:-dev} restart: always depends_on: *backend-deps environment: @@ -244,7 +244,7 @@ services: cvat_ui: container_name: cvat_ui - image: cvat/ui:${CVAT_VERSION:-v2.24.0} + image: cvat/ui:${CVAT_VERSION:-dev} restart: always depends_on: - cvat_server diff --git a/helm-chart/values.yaml b/helm-chart/values.yaml index e1138ca0a40c..ae0180efd972 100644 --- a/helm-chart/values.yaml +++ b/helm-chart/values.yaml @@ -139,7 +139,7 @@ cvat: additionalVolumeMounts: [] replicas: 1 image: cvat/server - tag: v2.24.0 + tag: dev imagePullPolicy: Always permissionFix: enabled: true @@ -162,7 +162,7 @@ cvat: frontend: replicas: 1 image: cvat/ui - tag: v2.24.0 + tag: dev imagePullPolicy: Always labels: {} # test: test From 12f886c6f0f0540e59abcb2addeb2e4bdeac4c68 Mon Sep 17 00:00:00 2001 From: Roman Donchenko Date: Fri, 20 Dec 2024 19:33:22 +0200 Subject: [PATCH 02/31] engine: remove deprecated imports (#8856) This is a continuation of #8626. --- cvat/apps/engine/background.py | 6 ++-- cvat/apps/engine/backup.py | 5 +-- cvat/apps/engine/cache.py | 27 +++++----------- cvat/apps/engine/cloud_provider.py | 31 ++++++++++--------- cvat/apps/engine/field_validation.py | 3 +- cvat/apps/engine/filters.py | 13 ++++---- cvat/apps/engine/frame_provider.py | 21 ++++++------- cvat/apps/engine/lazy_list.py | 3 +- cvat/apps/engine/location.py | 6 ++-- cvat/apps/engine/media_extractors.py | 22 ++++++------- .../migrations/0083_move_to_segment_chunks.py | 3 +- .../migrations/0084_honeypot_support.py | 2 +- cvat/apps/engine/mixins.py | 5 +-- cvat/apps/engine/models.py | 5 +-- cvat/apps/engine/permissions.py | 11 ++++--- cvat/apps/engine/schema.py | 3 +- cvat/apps/engine/serializers.py | 22 +++++++------ cvat/apps/engine/task.py | 27 ++++++++-------- cvat/apps/engine/task_validation.py | 3 +- cvat/apps/engine/tests/utils.py | 7 +++-- cvat/apps/engine/utils.py | 9 +++--- cvat/apps/engine/view_utils.py | 10 +++--- cvat/apps/engine/views.py | 11 ++++--- 23 files changed, 127 insertions(+), 128 deletions(-) diff --git a/cvat/apps/engine/background.py b/cvat/apps/engine/background.py index d9f9237e6d27..a3a2d34326b9 100644 --- a/cvat/apps/engine/background.py +++ b/cvat/apps/engine/background.py @@ -7,7 +7,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import datetime -from typing import Any, Callable, Dict, Optional, Union +from typing import Any, Callable, Optional, Union import django_rq from attrs.converters import to_bool @@ -170,7 +170,7 @@ class ExportArgs: format: str filename: str save_images: bool - location_config: Dict[str, Any] + location_config: dict[str, Any] @property def location(self) -> Location: @@ -515,7 +515,7 @@ class BackupExportManager(_ResourceExportManager): @dataclass class ExportArgs: filename: str - location_config: Dict[str, Any] + location_config: dict[str, Any] @property def location(self) -> Location: diff --git a/cvat/apps/engine/backup.py b/cvat/apps/engine/backup.py index 499700a3b4ef..3c8ba5678c24 100644 --- a/cvat/apps/engine/backup.py +++ b/cvat/apps/engine/backup.py @@ -10,10 +10,11 @@ import shutil import tempfile import uuid +from collections.abc import Collection, Iterable from enum import Enum from logging import Logger from tempfile import NamedTemporaryFile -from typing import Any, Collection, Dict, Iterable, Optional, Union +from typing import Any, Optional, Union from zipfile import ZipFile import django_rq @@ -650,7 +651,7 @@ def _calculate_segment_size(jobs): return segment_size, overlap @staticmethod - def _parse_segment_frames(*, jobs: Dict[str, Any]) -> JobFileMapping: + def _parse_segment_frames(*, jobs: dict[str, Any]) -> JobFileMapping: segments = [] for i, segment in enumerate(jobs): diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index f89ca741501e..43c2be7bc57e 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -13,22 +13,11 @@ import time import zipfile import zlib +from collections.abc import Collection, Generator, Iterator, Sequence from contextlib import ExitStack, closing from datetime import datetime, timezone from itertools import groupby, pairwise -from typing import ( - Any, - Callable, - Collection, - Generator, - Iterator, - Optional, - Sequence, - Tuple, - Type, - Union, - overload, -) +from typing import Any, Callable, Optional, Union, overload import attrs import av @@ -76,8 +65,8 @@ slogger = ServerLogManager(__name__) -DataWithMime = Tuple[io.BytesIO, str] -_CacheItem = Tuple[io.BytesIO, str, int, Union[datetime, None]] +DataWithMime = tuple[io.BytesIO, str] +_CacheItem = tuple[io.BytesIO, str, int, Union[datetime, None]] def enqueue_create_chunk_job( @@ -636,7 +625,7 @@ def _read_raw_images( @staticmethod def _read_raw_frames( db_task: Union[models.Task, int], frame_ids: Sequence[int] - ) -> Generator[Tuple[Union[av.VideoFrame, PIL.Image.Image], str, str], None, None]: + ) -> Generator[tuple[Union[av.VideoFrame, PIL.Image.Image], str, str], None, None]: if isinstance(db_task, int): db_task = models.Task.objects.get(pk=db_task) @@ -962,7 +951,7 @@ def prepare_preview_image(image: PIL.Image.Image) -> DataWithMime: def prepare_chunk( - task_chunk_frames: Iterator[Tuple[Any, str, int]], + task_chunk_frames: Iterator[tuple[Any, str, int]], *, quality: FrameQuality, db_task: models.Task, @@ -972,7 +961,7 @@ def prepare_chunk( db_data = db_task.data - writer_classes: dict[FrameQuality, Type[IChunkWriter]] = { + writer_classes: dict[FrameQuality, type[IChunkWriter]] = { FrameQuality.COMPRESSED: ( Mpeg4CompressedChunkWriter if db_data.compressed_chunk_type == models.DataChoice.VIDEO @@ -1005,7 +994,7 @@ def prepare_chunk( return buffer, get_chunk_mime_type_for_writer(writer_class) -def get_chunk_mime_type_for_writer(writer: Union[IChunkWriter, Type[IChunkWriter]]) -> str: +def get_chunk_mime_type_for_writer(writer: Union[IChunkWriter, type[IChunkWriter]]) -> str: if isinstance(writer, IChunkWriter): writer_class = type(writer) else: diff --git a/cvat/apps/engine/cloud_provider.py b/cvat/apps/engine/cloud_provider.py index f3fe3e6a28e1..b810304d73f9 100644 --- a/cvat/apps/engine/cloud_provider.py +++ b/cvat/apps/engine/cloud_provider.py @@ -7,11 +7,12 @@ import json import os import math -from abc import ABC, abstractmethod, abstractproperty +from abc import ABC, abstractmethod +from collections.abc import Iterator +from concurrent.futures import ThreadPoolExecutor, wait, FIRST_EXCEPTION from enum import Enum from io import BytesIO -from typing import Dict, List, Optional, Any, Callable, TypeVar, Iterator -from concurrent.futures import ThreadPoolExecutor, wait, FIRST_EXCEPTION +from typing import Optional, Any, Callable, TypeVar import boto3 from azure.core.exceptions import HttpResponseError, ResourceExistsError @@ -135,7 +136,8 @@ class _CloudStorage(ABC): def __init__(self, prefix: Optional[str] = None): self.prefix = prefix - @abstractproperty + @property + @abstractmethod def name(self): pass @@ -232,7 +234,7 @@ def optimally_image_download(self, key: str, chunk_size: int = 65536) -> NamedBy def bulk_download_to_memory( self, - files: List[str], + files: list[str], *, threads_number: Optional[int] = None, _use_optimal_downloading: bool = True, @@ -246,7 +248,7 @@ def bulk_download_to_memory( def bulk_download_to_dir( self, - files: List[str], + files: list[str], upload_dir: str, *, threads_number: Optional[int] = None, @@ -274,7 +276,7 @@ def _list_raw_content_on_one_page( prefix: str = "", next_token: Optional[str] = None, page_size: int = settings.BUCKET_CONTENT_MAX_PAGE_SIZE, - ) -> Dict: + ) -> dict: pass def list_files_on_one_page( @@ -284,7 +286,7 @@ def list_files_on_one_page( page_size: int = settings.BUCKET_CONTENT_MAX_PAGE_SIZE, _use_flat_listing: bool = False, _use_sort: bool = False, - ) -> Dict: + ) -> dict: if self.prefix and prefix and not (self.prefix.startswith(prefix) or prefix.startswith(self.prefix)): return { @@ -337,7 +339,7 @@ def list_files( self, prefix: str = "", _use_flat_listing: bool = False, - ) -> List[str]: + ) -> list[str]: all_files = [] next_token = None while True: @@ -349,7 +351,8 @@ def list_files( return all_files - @abstractproperty + @property + @abstractmethod def supported_actions(self): pass @@ -365,7 +368,7 @@ def get_cloud_storage_instance( cloud_provider: CloudProviderChoice, resource: str, credentials: str, - specific_attributes: Optional[Dict[str, Any]] = None, + specific_attributes: Optional[dict[str, Any]] = None, ): instance = None if cloud_provider == CloudProviderChoice.AWS_S3: @@ -529,7 +532,7 @@ def _list_raw_content_on_one_page( prefix: str = "", next_token: Optional[str] = None, page_size: int = settings.BUCKET_CONTENT_MAX_PAGE_SIZE, - ) -> Dict: + ) -> dict: # The structure of response looks like this: # { # 'CommonPrefixes': [{'Prefix': 'sub/'}], @@ -736,7 +739,7 @@ def _list_raw_content_on_one_page( prefix: str = "", next_token: Optional[str] = None, page_size: int = settings.BUCKET_CONTENT_MAX_PAGE_SIZE, - ) -> Dict: + ) -> dict: page = self._client.walk_blobs( maxresults=page_size, results_per_page=page_size, delimiter='/', **({'name_starts_with': prefix} if prefix else {}) @@ -852,7 +855,7 @@ def _list_raw_content_on_one_page( prefix: str = "", next_token: Optional[str] = None, page_size: int = settings.BUCKET_CONTENT_MAX_PAGE_SIZE, - ) -> Dict: + ) -> dict: iterator = self._client.list_blobs( bucket_or_name=self.name, max_results=page_size, page_size=page_size, fields='items(name),nextPageToken,prefixes', # https://cloud.google.com/storage/docs/json_api/v1/parameters#fields diff --git a/cvat/apps/engine/field_validation.py b/cvat/apps/engine/field_validation.py index bbfa58b5f3ea..e411284b3cde 100644 --- a/cvat/apps/engine/field_validation.py +++ b/cvat/apps/engine/field_validation.py @@ -2,7 +2,8 @@ # # SPDX-License-Identifier: MIT -from typing import Any, Sequence +from collections.abc import Sequence +from typing import Any from rest_framework import serializers diff --git a/cvat/apps/engine/filters.py b/cvat/apps/engine/filters.py index 663b6554e168..32355629d06d 100644 --- a/cvat/apps/engine/filters.py +++ b/cvat/apps/engine/filters.py @@ -3,8 +3,9 @@ # # SPDX-License-Identifier: MIT -from typing import Any, Dict, Tuple, List, Iterator, Optional, Iterable +from collections.abc import Iterator, Iterable from functools import reduce +from typing import Any, Optional import operator import json @@ -25,7 +26,7 @@ DEFAULT_FILTER_FIELDS_ATTR = 'filter_fields' DEFAULT_LOOKUP_MAP_ATTR = 'lookup_fields' -def get_lookup_fields(view, fields: Optional[Iterator[str]] = None) -> Dict[str, str]: +def get_lookup_fields(view, fields: Optional[Iterator[str]] = None) -> dict[str, str]: if fields is None: fields = getattr(view, DEFAULT_FILTER_FIELDS_ATTR, None) or [] @@ -134,7 +135,7 @@ def get_schema_operation_parameters(self, view): }] if ordering_fields else [] class JsonLogicFilter(filters.BaseFilterBackend): - Rules = Dict[str, Any] + Rules = dict[str, Any] filter_param = 'filter' filter_title = _('Filter') filter_description = _(dedent(""" @@ -191,7 +192,7 @@ def _parse_query(self, json_rules: str) -> Rules: return rules def apply_filter(self, - queryset: QuerySet, parsed_rules: Rules, *, lookup_fields: Dict[str, Any] + queryset: QuerySet, parsed_rules: Rules, *, lookup_fields: dict[str, Any] ) -> QuerySet: try: q_object = self._build_Q(parsed_rules, lookup_fields) @@ -362,7 +363,7 @@ class DotDict(dict): __setattr__ = dict.__setitem__ __delattr__ = dict.__delitem__ - def __init__(self, dct: Dict): + def __init__(self, dct: dict): for key, value in dct.items(): if isinstance(value, dict): value = self.__class__(value) @@ -454,7 +455,7 @@ class NonModelOrderingFilter(OrderingFilter, _NestedAttributeHandler): ?sort=-field1,-field2 """ - def get_ordering(self, request, queryset, view) -> Tuple[List[str], bool]: + def get_ordering(self, request, queryset, view) -> tuple[list[str], bool]: ordering = super().get_ordering(request, queryset, view) result, reverse = [], False for field in ordering: diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index a004256320aa..6b756543c7f3 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -11,6 +11,7 @@ from abc import ABCMeta, abstractmethod from bisect import bisect from collections import OrderedDict +from collections.abc import Iterator, Sequence from dataclasses import dataclass from enum import Enum, auto from io import BytesIO @@ -18,11 +19,7 @@ Any, Callable, Generic, - Iterator, Optional, - Sequence, - Tuple, - Type, TypeVar, Union, overload, @@ -53,7 +50,7 @@ class _ChunkLoader(metaclass=ABCMeta): def __init__( self, - reader_class: Type[IMediaReader], + reader_class: type[IMediaReader], *, reader_params: Optional[dict] = None, ) -> None: @@ -62,7 +59,7 @@ def __init__( self.reader_class = reader_class self.reader_params = reader_params - def load(self, chunk_id: int) -> RandomAccessIterator[Tuple[Any, str, int]]: + def load(self, chunk_id: int) -> RandomAccessIterator[tuple[Any, str, int]]: if self.chunk_id != chunk_id: self.unload() @@ -88,7 +85,7 @@ def read_chunk(self, chunk_id: int) -> DataWithMime: ... class _FileChunkLoader(_ChunkLoader): def __init__( self, - reader_class: Type[IMediaReader], + reader_class: type[IMediaReader], get_chunk_path_callback: Callable[[int], str], *, reader_params: Optional[dict] = None, @@ -108,7 +105,7 @@ def read_chunk(self, chunk_id: int) -> DataWithMime: class _BufferChunkLoader(_ChunkLoader): def __init__( self, - reader_class: Type[IMediaReader], + reader_class: type[IMediaReader], get_chunk_callback: Callable[[int], DataWithMime], *, reader_params: Optional[dict] = None, @@ -154,7 +151,7 @@ def _av_frame_to_png_bytes(cls, av_frame: av.VideoFrame) -> BytesIO: return BytesIO(result.tobytes()) def _convert_frame( - self, frame: Any, reader_class: Type[IMediaReader], out_type: FrameOutputType + self, frame: Any, reader_class: type[IMediaReader], out_type: FrameOutputType ) -> AnyFrame: if out_type == FrameOutputType.BUFFER: return ( @@ -451,7 +448,7 @@ def __init__(self, db_segment: models.Segment) -> None: db_data = db_segment.task.data - reader_class: dict[models.DataChoice, Tuple[Type[IMediaReader], Optional[dict]]] = { + reader_class: dict[models.DataChoice, tuple[type[IMediaReader], Optional[dict]]] = { models.DataChoice.IMAGESET: (ZipReader, None), models.DataChoice.VIDEO: ( VideoReader, @@ -523,7 +520,7 @@ def get_frame_index(self, frame_number: int) -> Optional[int]: return frame_index - def validate_frame_number(self, frame_number: int) -> Tuple[int, int, int]: + def validate_frame_number(self, frame_number: int) -> tuple[int, int, int]: frame_index = self.get_frame_index(frame_number) if frame_index is None: raise ValidationError(f"Incorrect requested frame number: {frame_number}") @@ -576,7 +573,7 @@ def _get_raw_frame( frame_number: int, *, quality: FrameQuality = FrameQuality.ORIGINAL, - ) -> Tuple[Any, str, Type[IMediaReader]]: + ) -> tuple[Any, str, type[IMediaReader]]: _, chunk_number, frame_offset = self.validate_frame_number(frame_number) loader = self._loaders[quality] chunk_reader = loader.load(chunk_number) diff --git a/cvat/apps/engine/lazy_list.py b/cvat/apps/engine/lazy_list.py index 61d2c8956209..e8a36a09641f 100644 --- a/cvat/apps/engine/lazy_list.py +++ b/cvat/apps/engine/lazy_list.py @@ -2,9 +2,10 @@ # # SPDX-License-Identifier: MIT +from collections.abc import Iterator from functools import wraps from itertools import islice -from typing import Any, Callable, Iterator, TypeVar, overload +from typing import Any, Callable, TypeVar, overload import attrs from attr import field diff --git a/cvat/apps/engine/location.py b/cvat/apps/engine/location.py index ac6ab77dc073..c9e216e24627 100644 --- a/cvat/apps/engine/location.py +++ b/cvat/apps/engine/location.py @@ -3,7 +3,7 @@ # SPDX-License-Identifier: MIT from enum import Enum -from typing import Any, Dict, Union, Optional +from typing import Any, Union, Optional from cvat.apps.engine.models import Location, Project, Task, Job @@ -15,11 +15,11 @@ def __str__(self): return self.value def get_location_configuration( - query_params: Dict[str, Any], + query_params: dict[str, Any], field_name: str, *, db_instance: Optional[Union[Project, Task, Job]] = None, -) -> Dict[str, Any]: +) -> dict[str, Any]: location = query_params.get('location') # handle resource import diff --git a/cvat/apps/engine/media_extractors.py b/cvat/apps/engine/media_extractors.py index 3e7b8e17a31b..ae1c7b9f7da8 100644 --- a/cvat/apps/engine/media_extractors.py +++ b/cvat/apps/engine/media_extractors.py @@ -15,13 +15,11 @@ import struct from abc import ABC, abstractmethod from bisect import bisect -from contextlib import ExitStack, closing, contextmanager +from collections.abc import Generator, Iterable, Iterator, Sequence +from contextlib import AbstractContextManager, ExitStack, closing, contextmanager from dataclasses import dataclass from enum import IntEnum -from typing import ( - Any, Callable, ContextManager, Generator, Iterable, Iterator, Optional, Protocol, - Sequence, Tuple, TypeVar, Union -) +from typing import Any, Callable, Optional, Protocol, TypeVar, Union import av import av.codec @@ -612,7 +610,7 @@ def iterate_frames( *, frame_filter: Union[bool, Iterable[int]] = True, video_stream: Optional[av.video.stream.VideoStream] = None, - ) -> Iterator[Tuple[av.VideoFrame, str, int]]: + ) -> Iterator[tuple[av.VideoFrame, str, int]]: """ If provided, frame_filter must be an ordered sequence in the ascending order. 'True' means using the frames configured in the reader object. @@ -673,14 +671,14 @@ def iterate_frames( if next_frame_filter_frame is None: return - def __iter__(self) -> Iterator[Tuple[av.VideoFrame, str, int]]: + def __iter__(self) -> Iterator[tuple[av.VideoFrame, str, int]]: return self.iterate_frames() def get_progress(self, pos): duration = self._get_duration() return pos / duration if duration else None - def _read_av_container(self) -> ContextManager[av.container.InputContainer]: + def _read_av_container(self) -> AbstractContextManager[av.container.InputContainer]: return _AvVideoReading().read_av_container(self._source_path[0]) def _decode_stream( @@ -771,7 +769,7 @@ def __init__(self, manifest_path: str, source_path: str, *, allow_threading: boo self.allow_threading = allow_threading - def _read_av_container(self) -> ContextManager[av.container.InputContainer]: + def _read_av_container(self) -> AbstractContextManager[av.container.InputContainer]: return _AvVideoReading().read_av_container(self.source_path) def _decode_stream( @@ -1032,11 +1030,11 @@ def _add_video_stream(self, container: av.container.OutputContainer, w, h, rate, return video_stream - FrameDescriptor = Tuple[av.VideoFrame, Any, Any] + FrameDescriptor = tuple[av.VideoFrame, Any, Any] def _peek_first_frame( self, frame_iter: Iterator[FrameDescriptor] - ) -> Tuple[Optional[FrameDescriptor], Iterator[FrameDescriptor]]: + ) -> tuple[Optional[FrameDescriptor], Iterator[FrameDescriptor]]: "Gets the first frame and returns the same full iterator" if not hasattr(frame_iter, '__next__'): @@ -1047,7 +1045,7 @@ def _peek_first_frame( def save_as_chunk( self, images: Iterator[FrameDescriptor], chunk_path: str - ) -> Sequence[Tuple[int, int]]: + ) -> Sequence[tuple[int, int]]: first_frame, images = self._peek_first_frame(images) if not first_frame: raise Exception('no images to save') diff --git a/cvat/apps/engine/migrations/0083_move_to_segment_chunks.py b/cvat/apps/engine/migrations/0083_move_to_segment_chunks.py index 8ef887d4c54b..4138d9295c87 100644 --- a/cvat/apps/engine/migrations/0083_move_to_segment_chunks.py +++ b/cvat/apps/engine/migrations/0083_move_to_segment_chunks.py @@ -1,8 +1,9 @@ # Generated by Django 4.2.13 on 2024-08-12 09:49 import os +from collections.abc import Iterable from itertools import islice -from typing import Iterable, TypeVar +from typing import TypeVar from django.db import migrations diff --git a/cvat/apps/engine/migrations/0084_honeypot_support.py b/cvat/apps/engine/migrations/0084_honeypot_support.py index 721d400ec386..fb44839c50bd 100644 --- a/cvat/apps/engine/migrations/0084_honeypot_support.py +++ b/cvat/apps/engine/migrations/0084_honeypot_support.py @@ -1,7 +1,7 @@ # Generated by Django 4.2.15 on 2024-09-23 13:11 -from typing import Collection from collections import defaultdict +from collections.abc import Collection import django.db.models.deletion from django.db import migrations, models diff --git a/cvat/apps/engine/mixins.py b/cvat/apps/engine/mixins.py index 3e48bf85327e..39f50ed31db4 100644 --- a/cvat/apps/engine/mixins.py +++ b/cvat/apps/engine/mixins.py @@ -8,12 +8,13 @@ import os import os.path import uuid +from collections.abc import Mapping from dataclasses import asdict, dataclass from pathlib import Path from tempfile import NamedTemporaryFile from unittest import mock from textwrap import dedent -from typing import Optional, Callable, Dict, Any, Mapping +from typing import Optional, Callable, Any from urllib.parse import urljoin import django_rq @@ -424,7 +425,7 @@ def export_dataset_v1( request, save_images: bool, *, - get_data: Optional[Callable[[int], Dict[str, Any]]] = None, + get_data: Optional[Callable[[int], dict[str, Any]]] = None, ) -> Response: if request.query_params.get("format"): callback = self.get_export_callback(save_images) diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index 527741497531..c25c75404eaf 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -10,9 +10,10 @@ import re import shutil import uuid +from collections.abc import Collection, Sequence from enum import Enum from functools import cached_property -from typing import Any, ClassVar, Collection, Dict, Optional, Sequence +from typing import Any, ClassVar, Optional from django.conf import settings from django.contrib.auth.models import User @@ -824,7 +825,7 @@ def update_or_create(self, *args, **kwargs: Any): return super().update_or_create(*args, **kwargs) - def _validate_constraints(self, obj: Dict[str, Any]): + def _validate_constraints(self, obj: dict[str, Any]): if 'type' not in obj: return diff --git a/cvat/apps/engine/permissions.py b/cvat/apps/engine/permissions.py index d01036fc9004..c5ddd4799c4c 100644 --- a/cvat/apps/engine/permissions.py +++ b/cvat/apps/engine/permissions.py @@ -4,7 +4,8 @@ # SPDX-License-Identifier: MIT from collections import namedtuple -from typing import Any, Dict, List, Optional, Sequence, Union, cast +from collections.abc import Sequence +from typing import Any, Optional, Union, cast from django.shortcuts import get_object_or_404 from django.conf import settings @@ -21,7 +22,7 @@ from .models import AnnotationGuide, CloudStorage, Issue, Job, Label, Project, Task from cvat.apps.engine.utils import is_dataset_export -def _get_key(d: Dict[str, Any], key_path: Union[str, Sequence[str]]) -> Optional[Any]: +def _get_key(d: dict[str, Any], key_path: Union[str, Sequence[str]]) -> Optional[Any]: """ Like dict.get(), but supports nested fields. If the field is missing, returns None. """ @@ -466,7 +467,7 @@ def __init__(self, **kwargs): self.url = settings.IAM_OPA_DATA_URL + '/tasks/allow' @staticmethod - def get_scopes(request, view, obj) -> List[Scopes]: + def get_scopes(request, view, obj) -> list[Scopes]: Scopes = __class__.Scopes scope = { ('list', 'GET'): Scopes.LIST, @@ -1191,7 +1192,7 @@ class Scopes(StrEnum): CANCEL = 'cancel' @classmethod - def create(cls, request, view, obj: Optional[RQJob], iam_context: Dict): + def create(cls, request, view, obj: Optional[RQJob], iam_context: dict): permissions = [] if view.basename == 'request': for scope in cls.get_scopes(request, view, obj): @@ -1207,7 +1208,7 @@ def __init__(self, **kwargs): self.url = settings.IAM_OPA_DATA_URL + '/requests/allow' @staticmethod - def get_scopes(request, view, obj) -> List[Scopes]: + def get_scopes(request, view, obj) -> list[Scopes]: Scopes = __class__.Scopes return [{ ('list', 'GET'): Scopes.LIST, diff --git a/cvat/apps/engine/schema.py b/cvat/apps/engine/schema.py index 5931381b403d..f3914a03dddd 100644 --- a/cvat/apps/engine/schema.py +++ b/cvat/apps/engine/schema.py @@ -3,7 +3,6 @@ # SPDX-License-Identifier: MIT import textwrap -from typing import Type from drf_spectacular.extensions import OpenApiSerializerExtension from drf_spectacular.plumbing import build_basic_type, force_instance @@ -15,7 +14,7 @@ def _copy_serializer( instance: serializers.Serializer, *, - _new_type: Type[serializers.Serializer] = None, + _new_type: type[serializers.Serializer] = None, **kwargs ) -> serializers.Serializer: _new_type = _new_type or type(instance) diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index cf16d885163c..9f772cd24e6d 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -5,6 +5,8 @@ from __future__ import annotations +from collections import OrderedDict +from collections.abc import Iterable, Sequence from contextlib import closing import warnings from copy import copy @@ -17,7 +19,7 @@ import string from tempfile import NamedTemporaryFile import textwrap -from typing import Any, Dict, Iterable, Optional, OrderedDict, Sequence, Union +from typing import Any, Optional, Union import django_rq from django.conf import settings @@ -367,9 +369,9 @@ def check_attribute_names_unique(attrs): @transaction.atomic def update_label( cls, - validated_data: Dict[str, Any], + validated_data: dict[str, Any], svg: str, - sublabels: Iterable[Dict[str, Any]], + sublabels: Iterable[dict[str, Any]], *, parent_instance: Union[models.Project, models.Task], parent_label: Optional[models.Label] = None @@ -483,7 +485,7 @@ def update_label( @classmethod @transaction.atomic def create_labels(cls, - labels: Iterable[Dict[str, Any]], + labels: Iterable[dict[str, Any]], *, parent_instance: Union[models.Project, models.Task], parent_label: Optional[models.Label] = None @@ -534,7 +536,7 @@ def create_labels(cls, @classmethod @transaction.atomic def update_labels(cls, - labels: Iterable[Dict[str, Any]], + labels: Iterable[dict[str, Any]], *, parent_instance: Union[models.Project, models.Task], parent_label: Optional[models.Label] = None @@ -3270,7 +3272,7 @@ class Meta: def _update_related_storages( instance: Union[models.Project, models.Task], - validated_data: Dict[str, Any], + validated_data: dict[str, Any], ) -> None: for storage_type in ('source_storage', 'target_storage'): new_conf = validated_data.pop(storage_type, None) @@ -3325,7 +3327,7 @@ def _update_related_storages( storage_instance.cloud_storage_id = new_cloud_storage_id storage_instance.save() -def _configure_related_storages(validated_data: Dict[str, Any]) -> Dict[str, Optional[models.Storage]]: +def _configure_related_storages(validated_data: dict[str, Any]) -> dict[str, Optional[models.Storage]]: storages = { 'source_storage': None, 'target_storage': None, @@ -3418,7 +3420,7 @@ class RequestDataOperationSerializer(serializers.Serializer): format = serializers.CharField(required=False, allow_null=True) function_id = serializers.CharField(required=False, allow_null=True) - def to_representation(self, rq_job: RQJob) -> Dict[str, Any]: + def to_representation(self, rq_job: RQJob) -> dict[str, Any]: parsed_rq_id: RQId = rq_job.parsed_rq_id return { @@ -3459,7 +3461,7 @@ class RequestSerializer(serializers.Serializer): result_id = serializers.IntegerField(required=False, allow_null=True) @extend_schema_field(UserIdentifiersSerializer()) - def get_owner(self, rq_job: RQJob) -> Dict[str, Any]: + def get_owner(self, rq_job: RQJob) -> dict[str, Any]: return UserIdentifiersSerializer(rq_job.meta[RQJobMetaField.USER]).data @extend_schema_field( @@ -3499,7 +3501,7 @@ def get_message(self, rq_job: RQJob) -> str: return message - def to_representation(self, rq_job: RQJob) -> Dict[str, Any]: + def to_representation(self, rq_job: RQJob) -> dict[str, Any]: representation = super().to_representation(rq_job) # FUTURE-TODO: support such statuses on UI diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 3fac8f03fe65..0f36674299fc 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -10,11 +10,12 @@ import re import rq import shutil +from collections.abc import Iterator, Sequence from copy import deepcopy from contextlib import closing from datetime import datetime, timezone from pathlib import Path -from typing import Any, Dict, Iterator, List, NamedTuple, Optional, Sequence, Tuple, Union +from typing import Any, NamedTuple, Optional, Union from urllib import parse as urlparse from urllib import request as urlrequest @@ -77,7 +78,7 @@ def create( ############################# Internal implementation for server API -JobFileMapping = List[List[str]] +JobFileMapping = list[list[str]] class SegmentParams(NamedTuple): start_frame: int @@ -91,10 +92,10 @@ class SegmentsParams(NamedTuple): overlap: int def _copy_data_from_share_point( - server_files: List[str], + server_files: list[str], upload_dir: str, server_dir: Optional[str] = None, - server_files_exclude: Optional[List[str]] = None, + server_files_exclude: Optional[list[str]] = None, ): job = rq.get_current_job() job.meta['status'] = 'Data are being copied from source..' @@ -304,7 +305,7 @@ def _validate_data(counter, manifest_files=None): return counter, task_modes[0] def _validate_job_file_mapping( - db_task: models.Task, data: Dict[str, Any] + db_task: models.Task, data: dict[str, Any] ) -> Optional[JobFileMapping]: job_file_mapping = data.get('job_file_mapping', None) @@ -343,7 +344,7 @@ def _validate_job_file_mapping( return job_file_mapping def _validate_validation_params( - db_task: models.Task, data: Dict[str, Any], *, is_backup_restore: bool = False + db_task: models.Task, data: dict[str, Any], *, is_backup_restore: bool = False ) -> Optional[dict[str, Any]]: params = data.get('validation_params', {}) if not params: @@ -382,7 +383,7 @@ def _validate_validation_params( return params def _validate_manifest( - manifests: List[str], + manifests: list[str], root_dir: Optional[str], *, is_in_cloud: bool, @@ -455,7 +456,7 @@ def _download_data(urls, upload_dir): def _download_data_from_cloud_storage( db_storage: models.CloudStorage, - files: List[str], + files: list[str], upload_dir: str, ): cloud_storage_instance = db_storage_to_storage_instance(db_storage) @@ -479,7 +480,7 @@ def _read_dataset_manifest(path: str, *, create_index: bool = False) -> ImageMan def _restore_file_order_from_manifest( extractor: ImageListReader, manifest: ImageManifestManager, upload_dir: str -) -> List[str]: +) -> list[str]: """ Restores file ordering for the "predefined" file sorting method of the task creation. Checks for extra files in the input. @@ -511,7 +512,7 @@ def _restore_file_order_from_manifest( return [input_files[fn] for fn in manifest_files] def _create_task_manifest_based_on_cloud_storage_manifest( - sorted_media: List[str], + sorted_media: list[str], cloud_storage_manifest_prefix: str, cloud_storage_manifest: ImageManifestManager, manifest: ImageManifestManager, @@ -536,7 +537,7 @@ def _add_prefix(properties): def _create_task_manifest_from_cloud_data( db_storage: models.CloudStorage, - sorted_media: List[str], + sorted_media: list[str], manifest: ImageManifestManager, dimension: models.DimensionType = models.DimensionType.DIM_2D, *, @@ -557,7 +558,7 @@ def _create_task_manifest_from_cloud_data( @transaction.atomic def _create_thread( db_task: Union[int, models.Task], - data: Dict[str, Any], + data: dict[str, Any], *, is_backup_restore: bool = False, is_dataset_import: bool = False, @@ -1598,7 +1599,7 @@ def save_chunks( frame_map = {} # frame number -> extractor frame number if isinstance(media_extractor, MEDIA_TYPES['video']['extractor']): - def _get_frame_size(frame_tuple: Tuple[av.VideoFrame, Any, Any]) -> int: + def _get_frame_size(frame_tuple: tuple[av.VideoFrame, Any, Any]) -> int: # There is no need to be absolutely precise here, # just need to provide the reasonable upper boundary. # Return bytes needed for 1 frame diff --git a/cvat/apps/engine/task_validation.py b/cvat/apps/engine/task_validation.py index 3f15b7d79716..fe76b4e99408 100644 --- a/cvat/apps/engine/task_validation.py +++ b/cvat/apps/engine/task_validation.py @@ -2,7 +2,8 @@ # # SPDX-License-Identifier: MIT -from typing import Generic, Mapping, Sequence, TypeVar +from collections.abc import Mapping, Sequence +from typing import Generic, TypeVar import numpy as np diff --git a/cvat/apps/engine/tests/utils.py b/cvat/apps/engine/tests/utils.py index 3d2a533d1e97..910323cac1f7 100644 --- a/cvat/apps/engine/tests/utils.py +++ b/cvat/apps/engine/tests/utils.py @@ -2,9 +2,10 @@ # # SPDX-License-Identifier: MIT +from collections.abc import Iterator, Sequence from contextlib import contextmanager from io import BytesIO -from typing import Any, Callable, Dict, Iterator, Sequence, TypeVar +from typing import Any, Callable, TypeVar import itertools import logging import os @@ -178,6 +179,6 @@ def get_paginated_collection( def filter_dict( - d: Dict[str, Any], *, keep: Sequence[str] = None, drop: Sequence[str] = None -) -> Dict[str, Any]: + d: dict[str, Any], *, keep: Sequence[str] = None, drop: Sequence[str] = None +) -> dict[str, Any]: return {k: v for k, v in d.items() if (not keep or k in keep) and (not drop or k not in drop)} diff --git a/cvat/apps/engine/utils.py b/cvat/apps/engine/utils.py index 13d1d354dd3d..dd4533538f5a 100644 --- a/cvat/apps/engine/utils.py +++ b/cvat/apps/engine/utils.py @@ -7,14 +7,13 @@ from itertools import islice import cv2 as cv from collections import namedtuple +from collections.abc import Generator, Iterable, Iterator, Mapping, Sequence import hashlib import importlib import sys import traceback from contextlib import suppress, nullcontext -from typing import ( - Any, Callable, Dict, Generator, Iterable, Iterator, Optional, Mapping, Sequence, TypeVar, Union -) +from typing import Any, Callable, Optional, TypeVar, Union import subprocess import os import urllib.parse @@ -264,7 +263,7 @@ def get_rq_job_meta( return meta def reverse(viewname, *, args=None, kwargs=None, - query_params: Optional[Dict[str, str]] = None, + query_params: Optional[dict[str, str]] = None, request: Optional[HttpRequest] = None, ) -> str: """ @@ -283,7 +282,7 @@ def reverse(viewname, *, args=None, kwargs=None, def get_server_url(request: HttpRequest) -> str: return request.build_absolute_uri('/') -def build_field_filter_params(field: str, value: Any) -> Dict[str, str]: +def build_field_filter_params(field: str, value: Any) -> dict[str, str]: """ Builds a collection filter query params for a single field and value. """ diff --git a/cvat/apps/engine/view_utils.py b/cvat/apps/engine/view_utils.py index 2acb8bac780f..6f5dc298a7b6 100644 --- a/cvat/apps/engine/view_utils.py +++ b/cvat/apps/engine/view_utils.py @@ -4,7 +4,7 @@ # NOTE: importing in the utils.py header leads to circular importing -from typing import Optional, Type +from typing import Optional from django.db.models.query import QuerySet from django.http.request import HttpRequest @@ -23,9 +23,9 @@ def make_paginated_response( queryset: QuerySet, *, viewset: GenericViewSet, - response_type: Optional[Type[HttpResponse]] = None, - serializer_type: Optional[Type[Serializer]] = None, - request: Optional[Type[HttpRequest]] = None, + response_type: Optional[type[HttpResponse]] = None, + serializer_type: Optional[type[Serializer]] = None, + request: Optional[type[HttpRequest]] = None, **serializer_params ): # Adapted from the mixins.ListModelMixin.list() @@ -54,7 +54,7 @@ def make_paginated_response( return response_type(serializer.data) -def list_action(serializer_class: Type[Serializer], **kwargs): +def list_action(serializer_class: type[Serializer], **kwargs): params = dict( detail=True, methods=["GET"], diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index 9692cfc2f750..eb39f6732c18 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -16,8 +16,9 @@ from contextlib import suppress from PIL import Image from types import SimpleNamespace -from typing import Optional, Any, Dict, List, Union, cast, Callable, Mapping, Iterable +from typing import Optional, Any, Union, cast, Callable from collections import namedtuple +from collections.abc import Mapping, Iterable from copy import copy from datetime import datetime from redis.exceptions import ConnectionError as RedisConnectionError @@ -1076,7 +1077,7 @@ def _maybe_append_upload_info_entry(self, filename: str): filename = self._prepare_upload_info_entry(filename) task_data.client_files.get_or_create(file=filename) - def _append_upload_info_entries(self, client_files: List[Dict[str, Any]]): + def _append_upload_info_entries(self, client_files: list[dict[str, Any]]): # batch version of _maybe_append_upload_info_entry() without optional insertion task_data = cast(Data, self._object.data) task_data.client_files.bulk_create([ @@ -1084,7 +1085,7 @@ def _append_upload_info_entries(self, client_files: List[Dict[str, Any]]): for cf in client_files ]) - def _sort_uploaded_files(self, uploaded_files: List[str], ordering: List[str]) -> List[str]: + def _sort_uploaded_files(self, uploaded_files: list[str], ordering: list[str]) -> list[str]: """ Applies file ordering for the "predefined" file sorting method of the task creation. @@ -3568,7 +3569,7 @@ def get_queryset(self): def queues(self) -> Iterable[DjangoRQ]: return (django_rq.get_queue(queue_name) for queue_name in self.SUPPORTED_QUEUES) - def _get_rq_jobs_from_queue(self, queue: DjangoRQ, user_id: int) -> List[RQJob]: + def _get_rq_jobs_from_queue(self, queue: DjangoRQ, user_id: int) -> list[RQJob]: job_ids = set(queue.get_job_ids() + queue.started_job_registry.get_job_ids() + queue.finished_job_registry.get_job_ids() + @@ -3588,7 +3589,7 @@ def _get_rq_jobs_from_queue(self, queue: DjangoRQ, user_id: int) -> List[RQJob]: return jobs - def _get_rq_jobs(self, user_id: int) -> List[RQJob]: + def _get_rq_jobs(self, user_id: int) -> list[RQJob]: """ Get all RQ jobs for a specific user and return them as a list of RQJob objects. From 5b7d12cce0b0c10a30cc2eaaa27aeb4d37f8c1ce Mon Sep 17 00:00:00 2001 From: Roman Donchenko Date: Tue, 24 Dec 2024 11:16:15 +0200 Subject: [PATCH 03/31] Centralize Python linter/formatter requirements (#8854) These are currently duplicated all over the codebase, often with mismatched constraints. Put them all in one requirements file instead. Fix a few minor issues while I'm at it: * Constrain black to the current major version to avoid New Year surprises (black can change styles between major versions). Constrain isort too, just in case. * Remove usages of `egrep`, which is deprecated (and unnecessary here). --- .github/workflows/black.yml | 2 +- .github/workflows/isort.yml | 2 +- .github/workflows/pylint.yml | 6 +-- cvat-cli/requirements/development.txt | 5 --- cvat-sdk/gen/requirements.txt | 1 - cvat/requirements/all.txt | 2 - cvat/requirements/development.in | 4 -- cvat/requirements/development.txt | 44 +------------------ cvat/requirements/testing.txt | 2 - dev/requirements.txt | 5 +++ .../contributing/development-environment.md | 2 +- site/requirements.txt | 1 - 12 files changed, 13 insertions(+), 63 deletions(-) delete mode 100644 cvat-cli/requirements/development.txt create mode 100644 dev/requirements.txt diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml index a74f70c54379..a86f236f49d7 100644 --- a/.github/workflows/black.yml +++ b/.github/workflows/black.yml @@ -8,7 +8,7 @@ jobs: - name: Run checks run: | - pipx install $(grep "^black" ./cvat-cli/requirements/development.txt) + pipx install $(grep "^black" ./dev/requirements.txt) echo "Black version: $(black --version)" diff --git a/.github/workflows/isort.yml b/.github/workflows/isort.yml index 19332d917030..bf90604cbb2f 100644 --- a/.github/workflows/isort.yml +++ b/.github/workflows/isort.yml @@ -25,7 +25,7 @@ jobs: UPDATED_DIRS="${{steps.files.outputs.all_changed_files}}" if [[ ! -z $UPDATED_DIRS ]]; then - pipx install $(egrep "isort.*" ./cvat-cli/requirements/development.txt) + pipx install $(grep "^isort" ./dev/requirements.txt) echo "isort version: $(isort --version-number)" echo "The dirs will be checked: $UPDATED_DIRS" diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index d808a823771f..05237f441988 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -19,11 +19,11 @@ jobs: CHANGED_FILES="${{steps.files.outputs.all_changed_files}}" if [[ ! -z $CHANGED_FILES ]]; then - pipx install $(egrep "^pylint==" ./cvat/requirements/development.txt) + pipx install $(grep "^pylint==" ./dev/requirements.txt) pipx inject pylint \ - $(egrep "^pylint-.+==" ./cvat/requirements/development.txt) \ - $(egrep "^django==" ./cvat/requirements/base.txt) + $(grep "^pylint-.\+==" ./dev/requirements.txt) \ + $(grep "^django==" ./cvat/requirements/base.txt) echo "Pylint version: "$(pylint --version | head -1) echo "The files will be checked: "$(echo $CHANGED_FILES) diff --git a/cvat-cli/requirements/development.txt b/cvat-cli/requirements/development.txt deleted file mode 100644 index 42a144087213..000000000000 --- a/cvat-cli/requirements/development.txt +++ /dev/null @@ -1,5 +0,0 @@ --r base.txt - -black>=24.1 -isort>=5.10.1 -pylint>=2.7.0 \ No newline at end of file diff --git a/cvat-sdk/gen/requirements.txt b/cvat-sdk/gen/requirements.txt index 18f397e59dc6..54c28f0b0007 100644 --- a/cvat-sdk/gen/requirements.txt +++ b/cvat-sdk/gen/requirements.txt @@ -1,5 +1,4 @@ # can't have a dependency on base.txt, because it depends on the generated file inflection >= 0.5.1 -isort>=5.10.1 ruamel.yaml>=0.17.21 diff --git a/cvat/requirements/all.txt b/cvat/requirements/all.txt index 4e05dcc9e85f..482db32ecf87 100644 --- a/cvat/requirements/all.txt +++ b/cvat/requirements/all.txt @@ -8,5 +8,3 @@ -r development.txt -r production.txt -r testing.txt - -# The following packages are considered to be unsafe in a requirements file: diff --git a/cvat/requirements/development.in b/cvat/requirements/development.in index ad5a5b6557ec..9c5e0662b52d 100644 --- a/cvat/requirements/development.in +++ b/cvat/requirements/development.in @@ -1,10 +1,6 @@ -r base.in -black>=24.1 django-extensions==3.0.8 django-silk==5.* -pylint-django==2.5.3 -pylint-plugin-utils==0.7 -pylint==2.14.5 rope==0.17.0 snakeviz==2.1.0 diff --git a/cvat/requirements/development.txt b/cvat/requirements/development.txt index b0c563374067..cc730b7916eb 100644 --- a/cvat/requirements/development.txt +++ b/cvat/requirements/development.txt @@ -1,4 +1,4 @@ -# SHA1:b71f4fe955f645187b7ccdf82b05f6a8d61eb3ab +# SHA1:cd8d0825dc4cfe37b22a489422105acba5483fe4 # # This file is autogenerated by pip-compile-multi # To update, run: @@ -6,61 +6,21 @@ # pip-compile-multi # -r base.txt -astroid==2.11.7 - # via pylint autopep8==2.3.1 # via django-silk -black==24.10.0 - # via -r cvat/requirements/development.in -dill==0.3.9 - # via pylint django-extensions==3.0.8 # via -r cvat/requirements/development.in django-silk==5.3.2 # via -r cvat/requirements/development.in gprof2dot==2024.6.6 # via django-silk -isort==5.13.2 - # via pylint -lazy-object-proxy==1.10.0 - # via astroid -mccabe==0.7.0 - # via pylint -mypy-extensions==1.0.0 - # via black -pathspec==0.12.1 - # via black -platformdirs==4.3.6 - # via - # black - # pylint pycodestyle==2.12.1 # via autopep8 -pylint==2.14.5 - # via - # -r cvat/requirements/development.in - # pylint-django - # pylint-plugin-utils -pylint-django==2.5.3 - # via -r cvat/requirements/development.in -pylint-plugin-utils==0.7 - # via - # -r cvat/requirements/development.in - # pylint-django rope==0.17.0 # via -r cvat/requirements/development.in snakeviz==2.1.0 # via -r cvat/requirements/development.in tomli==2.2.1 - # via - # autopep8 - # black - # pylint -tomlkit==0.13.2 - # via pylint + # via autopep8 tornado==6.4.2 # via snakeviz - -# The following packages are considered to be unsafe in a requirements file: -setuptools==75.6.0 - # via astroid diff --git a/cvat/requirements/testing.txt b/cvat/requirements/testing.txt index 90c8a13254c0..86ab66664526 100644 --- a/cvat/requirements/testing.txt +++ b/cvat/requirements/testing.txt @@ -14,5 +14,3 @@ lupa==1.14.1 # via fakeredis sortedcontainers==2.4.0 # via fakeredis - -# The following packages are considered to be unsafe in a requirements file: diff --git a/dev/requirements.txt b/dev/requirements.txt new file mode 100644 index 000000000000..4603689ae469 --- /dev/null +++ b/dev/requirements.txt @@ -0,0 +1,5 @@ +black==24.* +isort==5.* +pylint-django==2.5.3 +pylint-plugin-utils==0.7 +pylint==2.14.5 diff --git a/site/content/en/docs/contributing/development-environment.md b/site/content/en/docs/contributing/development-environment.md index e54929609e48..31fb2f755c7a 100644 --- a/site/content/en/docs/contributing/development-environment.md +++ b/site/content/en/docs/contributing/development-environment.md @@ -80,7 +80,7 @@ description: 'Installing a development environment for different operating syste python3 -m venv .env . .env/bin/activate pip install -U pip wheel setuptools - pip install -r cvat/requirements/development.txt + pip install -r cvat/requirements/development.txt -r dev/requirements.txt ``` Note that the `.txt` files in the `cvat/requirements` directory diff --git a/site/requirements.txt b/site/requirements.txt index e240c7a0f90e..10db0c33a9b0 100644 --- a/site/requirements.txt +++ b/site/requirements.txt @@ -1,5 +1,4 @@ gitpython inflection >= 0.5.1 -isort>=5.10.1 packaging toml From 3ba5715c3a565046534281bc5f04410b46a09986 Mon Sep 17 00:00:00 2001 From: Roman Donchenko Date: Tue, 24 Dec 2024 11:22:56 +0200 Subject: [PATCH 04/31] Remove all remaining deprecated imports (#8861) This concludes the series started in #8626. --- dev/update_version.py | 3 ++- site/build_docs.py | 4 ++-- site/process_sdk_docs.py | 8 ++++---- utils/dataset_manifest/core.py | 28 ++++++++++++++++------------ 4 files changed, 24 insertions(+), 19 deletions(-) diff --git a/dev/update_version.py b/dev/update_version.py index ed8d08a40f42..bc175aa16dd0 100755 --- a/dev/update_version.py +++ b/dev/update_version.py @@ -6,7 +6,8 @@ import sys from dataclasses import dataclass from pathlib import Path -from typing import Callable, Match, Pattern +from re import Match, Pattern +from typing import Callable SUCCESS_CHAR = "\u2714" diff --git a/site/build_docs.py b/site/build_docs.py index 2eca3a941330..a01c437ae64c 100755 --- a/site/build_docs.py +++ b/site/build_docs.py @@ -10,7 +10,7 @@ import subprocess import tempfile from pathlib import Path -from typing import Dict, Optional +from typing import Optional import git import toml @@ -98,7 +98,7 @@ def run_npm_install(): def run_hugo( destination_dir: os.PathLike, *, - extra_env_vars: Dict[str, str] = None, + extra_env_vars: dict[str, str] = None, executable: Optional[str] = "hugo", ): extra_kwargs = {} diff --git a/site/process_sdk_docs.py b/site/process_sdk_docs.py index 03324aea691b..4fb911b69718 100755 --- a/site/process_sdk_docs.py +++ b/site/process_sdk_docs.py @@ -12,13 +12,13 @@ import sys import textwrap from glob import iglob -from typing import Callable, List +from typing import Callable from inflection import underscore class Processor: - _reference_files: List[str] + _reference_files: list[str] def __init__(self, *, input_dir: str, site_root: str) -> None: self._input_dir = input_dir @@ -29,7 +29,7 @@ def __init__(self, *, input_dir: str, site_root: str) -> None: self._templates_dir = osp.join(self._site_root, "templates") @staticmethod - def _copy_files(src_dir: str, glob_pattern: str, dst_dir: str) -> List[str]: + def _copy_files(src_dir: str, glob_pattern: str, dst_dir: str) -> list[str]: copied_files = [] for src_path in iglob(osp.join(src_dir, glob_pattern), recursive=True): @@ -140,7 +140,7 @@ def _fix_page_links_and_references(self): with open(p, "w") as f: f.write(contents) - def _process_non_code_blocks(self, text: str, handlers: List[Callable[[str], str]]) -> str: + def _process_non_code_blocks(self, text: str, handlers: list[Callable[[str], str]]) -> str: """ Allows to process Markdown documents with passed callbacks. Callbacks are only executed outside code blocks. diff --git a/utils/dataset_manifest/core.py b/utils/dataset_manifest/core.py index 6a7c9d92f0d6..449e70d64098 100644 --- a/utils/dataset_manifest/core.py +++ b/utils/dataset_manifest/core.py @@ -9,7 +9,8 @@ import json import os -from abc import ABC, abstractmethod, abstractproperty, abstractstaticmethod +from abc import ABC, abstractmethod +from collections.abc import Iterator from contextlib import closing from itertools import islice from PIL import Image @@ -20,7 +21,7 @@ from .utils import SortingMethod, md5_hash, rotate_image, sort from .types import NamedBytesIO -from typing import Any, Dict, List, Union, Optional, Iterator, Tuple, Callable +from typing import Any, Union, Optional, Callable class VideoStreamReader: @@ -78,7 +79,7 @@ def validate_key_frame(self, container, video_stream, key_frame): return False return True - def __iter__(self) -> Iterator[Union[int, Tuple[int, int, str]]]: + def __iter__(self) -> Iterator[Union[int, tuple[int, int, str]]]: """ Iterate over video frames and yield key frames or indexes. @@ -143,12 +144,12 @@ def __iter__(self) -> Iterator[Union[int, Tuple[int, int, str]]]: class DatasetImagesReader: def __init__(self, - sources: Union[List[str], Iterator[NamedBytesIO]], + sources: Union[list[str], Iterator[NamedBytesIO]], *, start: int = 0, step: int = 1, stop: Optional[int] = None, - meta: Optional[Dict[str, List[str]]] = None, + meta: Optional[dict[str, list[str]]] = None, sorting_method: SortingMethod = SortingMethod.PREDEFINED, use_image_hash: bool = False, **kwargs @@ -196,7 +197,7 @@ def step(self): def step(self, value): self._step = int(value) - def _get_img_properties(self, image: Union[str, NamedBytesIO]) -> Dict[str, Any]: + def _get_img_properties(self, image: Union[str, NamedBytesIO]) -> dict[str, Any]: img = Image.open(image, mode='r') if self._data_dir: img_name = os.path.relpath(image, self._data_dir) @@ -469,7 +470,8 @@ def __getitem__(self, item): def index(self): return self._index - @abstractproperty + @property + @abstractmethod def data(self): ... @@ -665,7 +667,7 @@ def emulate_hierarchical_structure( prefix: str = "", default_prefix: Optional[str] = None, start_index: Optional[int] = None, - ) -> Dict: + ) -> dict: if default_prefix and prefix and not (default_prefix.startswith(prefix) or prefix.startswith(default_prefix)): return { @@ -727,12 +729,12 @@ def emulate_hierarchical_structure( 'next': next_start_index, } - def reorder(self, reordered_images: List[str]) -> None: + def reorder(self, reordered_images: list[str]) -> None: """ The method takes a list of image names and reorders its content based on this new list. Due to the implementation of Honeypots, the reordered list of image names may contain duplicates. """ - unique_images: Dict[str, Any] = {} + unique_images: dict[str, Any] = {} for _, image_details in self: if image_details.full_name not in unique_images: unique_images[image_details.full_name] = image_details @@ -766,11 +768,13 @@ def _validate_type(self, _dict): if not _dict['type'] == self.TYPE: raise InvalidManifestError('Incorrect type field') - @abstractproperty + @property + @abstractmethod def validators(self): pass - @abstractstaticmethod + @staticmethod + @abstractmethod def _validate_first_item(_dict): pass From 2fd48c8b5362dabe3e28ac4dbb5ed111f8190deb Mon Sep 17 00:00:00 2001 From: Roman Donchenko Date: Tue, 24 Dec 2024 12:18:06 +0200 Subject: [PATCH 05/31] Remove the `ModelProvider` interface (#8860) It was only used in the Enterprise version, so I moved it there. --- cvat-core/src/lambda-manager.ts | 6 ------ cvat-ui/src/cvat-core-wrapper.ts | 2 -- 2 files changed, 8 deletions(-) diff --git a/cvat-core/src/lambda-manager.ts b/cvat-core/src/lambda-manager.ts index 66733d7ed236..cfed3d474329 100644 --- a/cvat-core/src/lambda-manager.ts +++ b/cvat-core/src/lambda-manager.ts @@ -8,12 +8,6 @@ import { ArgumentError } from './exceptions'; import MLModel from './ml-model'; import { RQStatus, ShapeType } from './enums'; -export interface ModelProvider { - name: string; - icon: string; - attributes: Record; -} - export interface InteractorResults { mask: number[][]; points?: [number, number][]; diff --git a/cvat-ui/src/cvat-core-wrapper.ts b/cvat-ui/src/cvat-core-wrapper.ts index ba7b47fcfa54..fc255dd53324 100644 --- a/cvat-ui/src/cvat-core-wrapper.ts +++ b/cvat-ui/src/cvat-core-wrapper.ts @@ -10,7 +10,6 @@ import ObjectState from 'cvat-core/src/object-state'; import Webhook from 'cvat-core/src/webhook'; import MLModel from 'cvat-core/src/ml-model'; import CloudStorage from 'cvat-core/src/cloud-storage'; -import { ModelProvider } from 'cvat-core/src/lambda-manager'; import { Label, Attribute, } from 'cvat-core/src/labels'; @@ -121,7 +120,6 @@ export type { SerializedAttribute, SerializedLabel, StorageData, - ModelProvider, APIWrapperEnterOptions, QualitySummary, CVATCore, From d966c1e4e4ab213998ec54dd85e7db22019c39c9 Mon Sep 17 00:00:00 2001 From: Roman Donchenko Date: Tue, 24 Dec 2024 18:40:56 +0200 Subject: [PATCH 06/31] Fix Pylint warnings in migrations (#8869) --- .../engine/migrations/0024_auto_20191023_1025.py | 4 ++-- .../engine/migrations/0034_auto_20201125_1426.py | 12 ++++++------ cvat/apps/engine/migrations/0038_manifest.py | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/cvat/apps/engine/migrations/0024_auto_20191023_1025.py b/cvat/apps/engine/migrations/0024_auto_20191023_1025.py index c8aefe7b7774..1946e08e47e2 100644 --- a/cvat/apps/engine/migrations/0024_auto_20191023_1025.py +++ b/cvat/apps/engine/migrations/0024_auto_20191023_1025.py @@ -79,7 +79,7 @@ def migrate_task_data(db_task_id, db_data_id, original_video, original_images, s compressed_chunk_path = os.path.join(compressed_cache_dir, '{}.zip'.format(chunk_idx)) compressed_chunk_writer.save_as_chunk(chunk_images, compressed_chunk_path) - preview = reader.get_preview() + preview = reader.get_preview(0) preview.save(os.path.join(db_data_dir, 'preview.jpeg')) else: original_chunk_writer = ZipChunkWriter(100) @@ -146,7 +146,7 @@ def migrate_task_data(db_task_id, db_data_id, original_video, original_images, s original_chunk_path = os.path.join(original_cache_dir, '{}.zip'.format(chunk_idx)) original_chunk_writer.save_as_chunk(chunk_images, original_chunk_path) - preview = reader.get_preview() + preview = reader.get_preview(0) preview.save(os.path.join(db_data_dir, 'preview.jpeg')) shutil.rmtree(old_db_task_dir) return_dict[db_task_id] = (True, '') diff --git a/cvat/apps/engine/migrations/0034_auto_20201125_1426.py b/cvat/apps/engine/migrations/0034_auto_20201125_1426.py index 457861a3942c..311b21655b9d 100644 --- a/cvat/apps/engine/migrations/0034_auto_20201125_1426.py +++ b/cvat/apps/engine/migrations/0034_auto_20201125_1426.py @@ -6,12 +6,12 @@ import django.db.models.deletion def create_profile(apps, schema_editor): - User = apps.get_model('auth', 'User') - Profile = apps.get_model('engine', 'Profile') - for user in User.objects.all(): - profile = Profile() - profile.user = user - profile.save() + User = apps.get_model('auth', 'User') + Profile = apps.get_model('engine', 'Profile') + for user in User.objects.all(): + profile = Profile() + profile.user = user + profile.save() class Migration(migrations.Migration): diff --git a/cvat/apps/engine/migrations/0038_manifest.py b/cvat/apps/engine/migrations/0038_manifest.py index ec96045ae69c..002a0326c2dc 100644 --- a/cvat/apps/engine/migrations/0038_manifest.py +++ b/cvat/apps/engine/migrations/0038_manifest.py @@ -110,7 +110,7 @@ def migrate2manifest(apps, shema_editor): if db_data.storage == StorageChoice.SHARE: def _get_frame_step(str_): - match = search("step\s*=\s*([1-9]\d*)", str_) + match = search(r"step\s*=\s*([1-9]\d*)", str_) return int(match.group(1)) if match else 1 logger.info('Data is located on the share, metadata update has been started') manifest.step = _get_frame_step(db_data.frame_filter) From 6e3b5f80122fa2dcd67f25669ce8e7f243349bc5 Mon Sep 17 00:00:00 2001 From: PushpakBhoge <47291048+PushpakBhoge@users.noreply.github.com> Date: Wed, 25 Dec 2024 17:52:31 +0530 Subject: [PATCH 07/31] added `cvat_worker_chunk` service in `docker-compose.external_db.yml` (#8871) ### Motivation and context There was an oversight in the `docker-compose.external_db.yml` file where the newly added service, `cvat_worker_chunks`, was not configured to extend the external database environment variables. This resulted in the `cvat_worker_chunks` container failing to start properly, as it was stuck in an infinite wait state for a non-existent database connection. Consequently, jobs failed to open, and attempting to access them repeatedly caused the Data API to return a 429 status code. An existing issue related to this problem was reported here: https://github.com/cvat-ai/cvat/issues/8846. However, the author closed the issue without submitting a pull request to resolve it. ### How has this been tested? ### Checklist - [x] I submit my changes into the `develop` branch - [ ] I have created a changelog fragment - [~] I have updated the documentation accordingly - [~] I have added tests to cover my changes - [x] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) - [ ] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/cvat-ai/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/cvat-ai/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/cvat-ai/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/cvat-ai/cvat/tree/develop/cvat-ui#versioning)) ### License - [x] I submit _my code changes_ under the same [MIT License]( https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. ## Summary by CodeRabbit - **New Features** - Introduced a new service for improved chunk processing in the CVAT application deployment. --- docker-compose.external_db.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.external_db.yml b/docker-compose.external_db.yml index decd1e9ed141..8112c59fd4f4 100644 --- a/docker-compose.external_db.yml +++ b/docker-compose.external_db.yml @@ -27,6 +27,7 @@ services: cvat_worker_import: *backend-settings cvat_worker_quality_reports: *backend-settings cvat_worker_webhooks: *backend-settings + cvat_worker_chunks: *backend-settings secrets: postgres_password: From 85223f5cac1b149e7a42e591aaf77f54a7161ad8 Mon Sep 17 00:00:00 2001 From: cuiweiyuan Date: Wed, 25 Dec 2024 20:23:17 +0800 Subject: [PATCH 08/31] chore: fix some typos in comment (#8868) ### Motivation and context fix some typos in comment ### How has this been tested? ### Checklist - [x] I submit my changes into the `develop` branch - [ ] I have created a changelog fragment - [ ] I have updated the documentation accordingly - [ ] I have added tests to cover my changes - [ ] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) - [ ] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/cvat-ai/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/cvat-ai/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/cvat-ai/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/cvat-ai/cvat/tree/develop/cvat-ui#versioning)) ### License - [ ] I submit _my code changes_ under the same [MIT License]( https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. ## Summary by CodeRabbit - **Bug Fixes** - Corrected typographical errors in comments and messages across various components, enhancing clarity. - Updated comments related to ingress configuration in the Helm chart for better understanding. Signed-off-by: cuiweiyuan --- cvat-core/src/annotations-actions/base-action.ts | 2 +- cvat-core/src/annotations-actions/base-shapes-action.ts | 2 +- cvat-ui/src/utils/is-able-to-change-frame.ts | 2 +- helm-chart/values.yaml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cvat-core/src/annotations-actions/base-action.ts b/cvat-core/src/annotations-actions/base-action.ts index 2ec2148b24c7..8a0abba4b32d 100644 --- a/cvat-core/src/annotations-actions/base-action.ts +++ b/cvat-core/src/annotations-actions/base-action.ts @@ -53,7 +53,7 @@ export function validateClientIDs(collection: Partial) { collection.tracks ?? [], collection.tags ?? [], ).forEach((object) => { - // clientID is required to correct collection filtering and commiting in annotations actions logic + // clientID is required to correct collection filtering and committing in annotations actions logic if (typeof object.clientID !== 'number') { throw new Error('ClientID is undefined when running annotations action, but required'); } diff --git a/cvat-core/src/annotations-actions/base-shapes-action.ts b/cvat-core/src/annotations-actions/base-shapes-action.ts index 9eb65f052ee4..e5223f085d2d 100644 --- a/cvat-core/src/annotations-actions/base-shapes-action.ts +++ b/cvat-core/src/annotations-actions/base-shapes-action.ts @@ -129,7 +129,7 @@ export async function run( } } - await showMessageWithPause('Commiting handled objects', 100, 1500); + await showMessageWithPause('Committing handled objects', 100, 1500); if (cancelled()) { return; } diff --git a/cvat-ui/src/utils/is-able-to-change-frame.ts b/cvat-ui/src/utils/is-able-to-change-frame.ts index 3cbc127a8a86..d86b6357cd88 100644 --- a/cvat-ui/src/utils/is-able-to-change-frame.ts +++ b/cvat-ui/src/utils/is-able-to-change-frame.ts @@ -21,7 +21,7 @@ export default function isAbleToChangeFrame(frame?: number): boolean { if (typeof frame === 'number') { if (meta.includedFrames) { // frame argument comes in job coordinates - // hovewer includedFrames contains absolute data values + // however includedFrames contains absolute data values frameInTheJob = meta.includedFrames.includes(meta.getDataFrameNumber(frame - job.startFrame)); } diff --git a/helm-chart/values.yaml b/helm-chart/values.yaml index ae0180efd972..782840f2ed28 100644 --- a/helm-chart/values.yaml +++ b/helm-chart/values.yaml @@ -475,7 +475,7 @@ ingress: ## kubernetes.io/ingress.class: nginx ## annotations: {} - ## @param ingress.className IngressClass that will be be used to implement the Ingress (Kubernetes 1.18+) + ## @param ingress.className IngressClass that will be used to implement the Ingress (Kubernetes 1.18+) ## This is supported in Kubernetes 1.18+ and required if you have more than one IngressClass marked as the default for your cluster ## ref: https://kubernetes.io/blog/2020/04/02/improvements-to-the-ingress-api-in-kubernetes-1.18/ ## From 9a554c46b0f5f389eca8b5e53aaa4dcf34e3c98f Mon Sep 17 00:00:00 2001 From: Oleg Valiulin Date: Wed, 25 Dec 2024 12:40:20 +0000 Subject: [PATCH 09/31] E2E test of issue #8785: Undefined when reading getUpdated (#8843) Adding a test to reproduce the issue fixed in GH-8785 ### Motivation and context The issue dealt with inconsistent state of the user's job cache after he deletes a frame, tries to save the job, suddenly gets an unexpected network error from the server, then tries to save the job again. ### How has this been tested? ##### Arranged state (before() hook) - Check deleted frame visibility box form the user's pop down menu (to confirm deletion later) - Open a job from a task - Pick a frame in the UI ##### Test case - Delete the chosen frame - Confirm that the deletion did happen (assert that a .restore element is visible) - Try to save - but instead of 200 receive a stubbed response with 502 - Try to save again - this time yielding 200 OK without no unexpected errors ### Checklist - [ ] I submit my changes into the `develop` branch - [ ] I have created a changelog fragment - [ ] I have updated the documentation accordingly - [ ] I have added tests to cover my changes - [ ] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) - [ ] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/cvat-ai/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/cvat-ai/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/cvat-ai/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/cvat-ai/cvat/tree/develop/cvat-ui#versioning)) ### License - [ ] I submit _my code changes_ under the same [MIT License]( https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. ## Summary by CodeRabbit - **New Features** - Introduced a new end-to-end test for job metadata consistency after frame deletion. - **Bug Fixes** - Validated behavior when saving a job after frame deletion, ensuring proper error handling and recovery. --------- Co-authored-by: Oleg Valiulin Co-authored-by: Andrey Zhavoronkov Co-authored-by: Kirill Lakhov --- .gitignore | 5 +- .../e2e/actions_objects2/case_delete_frame.js | 8 +-- .../issue_8785_update_job_metadata.js | 59 +++++++++++++++++++ tests/cypress/support/commands.js | 8 +++ 4 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 tests/cypress/e2e/issues_prs2/issue_8785_update_job_metadata.js diff --git a/.gitignore b/.gitignore index c375c7df4e7e..2c5deb225eae 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,6 @@ /share/ /static/ /db.sqlite3 -/.*env* /keys /logs /profiles @@ -49,8 +48,8 @@ yarn-error.log* # Ignore all the installed packages node_modules -venv/ -.venv/ +/*env*/ +/.*env* # Ignore all js dists cvat-data/dist diff --git a/tests/cypress/e2e/actions_objects2/case_delete_frame.js b/tests/cypress/e2e/actions_objects2/case_delete_frame.js index 393fbc17b207..c0b3b34a0c99 100644 --- a/tests/cypress/e2e/actions_objects2/case_delete_frame.js +++ b/tests/cypress/e2e/actions_objects2/case_delete_frame.js @@ -40,12 +40,8 @@ context('Delete frame from job.', () => { cy.checkFrameNum(frame + 1); }); - it('Change deleted frame visability.', () => { - cy.openSettings(); - cy.get('.cvat-workspace-settings-show-deleted').within(() => { - cy.get('[type="checkbox"]').should('not.be.checked').check(); - }); - cy.closeSettings(); + it('Change deleted frame visibility.', () => { + cy.checkDeletedFrameVisibility(); }); it('Check previous frame available and deleted.', () => { diff --git a/tests/cypress/e2e/issues_prs2/issue_8785_update_job_metadata.js b/tests/cypress/e2e/issues_prs2/issue_8785_update_job_metadata.js new file mode 100644 index 000000000000..f34d3417c577 --- /dev/null +++ b/tests/cypress/e2e/issues_prs2/issue_8785_update_job_metadata.js @@ -0,0 +1,59 @@ +// Copyright (C) 2024 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +/// + +import { taskName } from '../../support/const'; + +context('The UI remains stable even when the metadata request fails.', () => { + const issueId = '8785'; + + function clickDeleteFrame() { + cy.get('.cvat-player-delete-frame').click(); + cy.get('.cvat-modal-delete-frame').within(() => { + cy.contains('button', 'Delete').click(); + }); + } + function clickSave() { + cy.get('button').contains('Save').click({ force: true }); + cy.get('button').contains('Save').trigger('mouseout'); + } + + before(() => { + cy.checkDeletedFrameVisibility(); + cy.openTaskJob(taskName); + cy.goToNextFrame(1); + }); + + describe(`Testing issue ${issueId}`, () => { + it('Crash on Save job. Save again.', () => { + const badResponse = { statusCode: 502, body: 'A horrible network error' }; + + cy.on('uncaught:exception', (err) => { + expect(err.code).to.equal(badResponse.statusCode); + expect(err.message).to.include(badResponse.body); + return false; + }); + + const routeMatcher = { + url: '/api/jobs/**/data/meta**', + method: 'PATCH', + times: 1, // cancels the intercept without retries + }; + + cy.intercept(routeMatcher, badResponse).as('patchError'); + + clickDeleteFrame(); + cy.get('.cvat-player-restore-frame').should('be.visible'); + + clickSave(); + cy.wait('@patchError').then((intercept) => { + expect(intercept.response.body).to.equal(badResponse.body); + expect(intercept.response.statusCode).to.equal(badResponse.statusCode); + }); + + cy.saveJob('PATCH', 200); + }); + }); +}); diff --git a/tests/cypress/support/commands.js b/tests/cypress/support/commands.js index 9941a9b0d5c3..42b7d2772375 100644 --- a/tests/cypress/support/commands.js +++ b/tests/cypress/support/commands.js @@ -1683,6 +1683,14 @@ Cypress.Commands.add('hideTooltips', () => { }); }); +Cypress.Commands.add('checkDeletedFrameVisibility', () => { + cy.openSettings(); + cy.get('.cvat-workspace-settings-show-deleted').within(() => { + cy.get('[type="checkbox"]').should('not.be.checked').check(); + }); + cy.closeSettings(); +}); + Cypress.Commands.overwrite('visit', (orig, url, options) => { orig(url, options); cy.closeModalUnsupportedPlatform(); From d2c071357a361e6b095b4639ba97340dc00dab95 Mon Sep 17 00:00:00 2001 From: Dmitrii Lavrukhin Date: Wed, 25 Dec 2024 16:43:43 +0400 Subject: [PATCH 10/31] yolov8 docs update (#8864) ### Motivation and context ### How has this been tested? ### Checklist - [ ] I submit my changes into the `develop` branch - [ ] I have created a changelog fragment - [ ] I have updated the documentation accordingly - [ ] I have added tests to cover my changes - [ ] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) - [ ] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/cvat-ai/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/cvat-ai/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/cvat-ai/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/cvat-ai/cvat/tree/develop/cvat-ui#versioning)) ### License - [ ] I submit _my code changes_ under the same [MIT License]( https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. ## Summary by CodeRabbit - **New Features** - Introduced a new "Import" section in the YOLOv8 data formats documentation, detailing requirements for importing datasets. - Provided an example of the expected directory layout for zip archives containing images and labels. - **Documentation** - Expanded the existing documentation to include guidance on importing datasets alongside the previously available export information. --------- Co-authored-by: Maxim Zhiltsov --- .../manual/advanced/formats/format-yolov8.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/site/content/en/docs/manual/advanced/formats/format-yolov8.md b/site/content/en/docs/manual/advanced/formats/format-yolov8.md index 4d2975900ab8..9fc4e0aba127 100644 --- a/site/content/en/docs/manual/advanced/formats/format-yolov8.md +++ b/site/content/en/docs/manual/advanced/formats/format-yolov8.md @@ -126,3 +126,24 @@ is named to correspond with its associated image file. For example, `frame_000001.txt` serves as the annotation for the `frame_000001.jpg` image. + +## Import + +Uploaded file: a zip archive of the same structure as above. + +For compatibility with other tools exporting in Ultralytics YOLO format +(e.g. [roboflow](https://roboflow.com/formats/yolov8-pytorch-txt)), +CVAT supports datasets with the inverted directory order of subset and "images" or "labels", +i.e. both `train/images/`, `images/train/` are valid inputs. +```bash +archive.zip/ + ├── train/ + │ ├── images/ # directory with images for train subset + │ │ ├── image1.jpg + │ │ ├── image2.jpg + │ │ └── ... + │ ├── labels/ # directory with annotations for train subset + │ │ ├── image1.txt + │ │ ├── image2.txt + │ │ └── ... +``` From 7ce704d6b4bee6adc7e78fe2decd845deefdfc63 Mon Sep 17 00:00:00 2001 From: Maria Khrustaleva Date: Wed, 25 Dec 2024 16:37:00 +0100 Subject: [PATCH 11/31] Update documentation for social authentication with Amazon Cognito (#8557) Added more details on how to configure Amazon Cognito pool --- .../social-accounts-configuration.md | 98 ++++++++++++------ site/content/en/images/cognito_pool_1.png | Bin 0 -> 126376 bytes site/content/en/images/cognito_pool_2.png | Bin 0 -> 48508 bytes .../images/login_page_with_amazon_cognito.png | Bin 0 -> 334860 bytes 4 files changed, 69 insertions(+), 29 deletions(-) create mode 100644 site/content/en/images/cognito_pool_1.png create mode 100644 site/content/en/images/cognito_pool_2.png create mode 100644 site/content/en/images/login_page_with_amazon_cognito.png diff --git a/site/content/en/docs/enterprise/social-accounts-configuration.md b/site/content/en/docs/enterprise/social-accounts-configuration.md index 83b7f463a27e..ee1dd7d4f322 100644 --- a/site/content/en/docs/enterprise/social-accounts-configuration.md +++ b/site/content/en/docs/enterprise/social-accounts-configuration.md @@ -106,32 +106,72 @@ There are 2 basic steps to enable GitHub account authentication. > but don't forget to add required permissions. >
In the **Permission** > **Account permissions** > **Email addresses** must be set to **read-only**. -## Enable authentication with an Amazon Cognito - -To enable authentication, do the following: - -1. Create a user pool. For more information, - see [Amazon Cognito user pools](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-identity-pools.html) -2. Fill in the name field, set the homepage URL (for example: `https://localhost:8080`), - and authentication callback URL (for example: `https://localhost:8080/api/auth/social/amazon-cognito/login/callback/`). -3. Create configuration file in CVAT: - - 1. Create the `auth_config.yml` file with the following content: - - ```yaml - --- - social_account: - enabled: true - amazon_cognito: - client_id: - client_secret: - domain: https://.auth.us-east-1.amazoncognito.com - ``` - - 2. Set `AUTH_CONFIG_PATH="` environment variable. - -3. In a terminal, run the following command: - - ```bash - docker compose -f docker-compose.yml -f docker-compose.dev.yml -f docker-compose.override.yml up -d --build - ``` +## Enable authentication with Amazon Cognito + +To enable authentication with Amazon Cognito for your CVAT instance, follow these steps: + +1. Create an **[Amazon Cognito pool](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-identity-pools.html)** + (_Optional_) +1. Set up a new app client +1. Configure social authentication in CVAT + +Now, let’s dive deeper into how to accomplish these steps. + +### Amazon Cognito pool creation + +This step is optional and should only be performed if a user pool has not already been created. +To create a user pool, follow these instructions: +1. Go to the [AWS Management Console](https://console.aws.amazon.com/console/home) +1. Locate `Cognito` in the list of services +1. Click `Create user pool` +1. Fill in the required fields + +### App client creation + +To create a new app client, follow these steps: +1. Go to the details page of the created user pool +1. Find the `App clients` item in the menu on the left +1. Click `Create app client` +1. Fill out the form as shown bellow: + ![](/images/cognito_pool_1.png) + - `Application type`: `Traditional web application` + - `Application name`: Specify a desired name, or leave the autogenerated one + - `Return URL` (_optional_): Specify the CVAT redirect URL + (`:///api/auth/social/amazon-cognito/login/callback/`). + This setting can also be updated or specified later after the app client is created. +1. Navigate to the `Login pages` tab of the created app client +1. Check the parameters in the `Managed login pages configuration` section and edit them if needed: + ![](/images/cognito_pool_2.png) + - `Allowed callback URLs`: Must be set to the CVAT redirect URL + - `Identity providers`: Must be specified + - `OAuth grant types`: The `Authorization code grant` must be selected + - `OpenID Connect scopes`: `OpenID`, `Profile`, `Email` scopes must be selected + +### Setting up social authentication in CVAT + +To configure social authentication in CVAT, create a configuration file +(`auth_config.yml`) with the following content: + ```yaml + --- + social_account: + enabled: true + amazon_cognito: + client_id: + client_secret: + domain: or + https://.auth.us-east-1.amazoncognito.com + ``` +To find the `client_id` and `client_secret` values, navigate to the created app client page +and check the `App client information` section. To find `domain`, look for the `Domain` item in the list on the left. + +Once the configuration file is updated, several environment variables must be exported before running CVAT: + ```bash + export AUTH_CONFIG_PATH="" + export CVAT_HOST="" + # cvat_port is optional + export CVAT_BASE_URL="://${CVAT_HOST}:" + ``` + +Start the CVAT enterprise instance as usual. +That's it! On the CVAT login page, you should now see the option `Continue with Amazon Cognito`. +![](/images/login_page_with_amazon_cognito.png) diff --git a/site/content/en/images/cognito_pool_1.png b/site/content/en/images/cognito_pool_1.png new file mode 100644 index 0000000000000000000000000000000000000000..7cfc8ac0352142d1684a7d257126efdd8850c1a8 GIT binary patch literal 126376 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⋘7kqO$40gJkQ_R>MAV@04Xgmf`Y2P4?oXIGy-Ki|HanqmYBA3Nc#FLLfGZc%G; z=Fsx-pxWND;{pPH@aWS4mZ6IdaIFumwm_RulZKcbJ~&O3%;6a z-qOhQu16l~<6-lN2EZ2DyiZmX{LX*$e6we_HCb6v;eT5=-z%)I=Ovw1A99%0=l8&Q z$vm;lc3*E^wU&zrwD5buN2lb{qdwS(OIx{1(BpiH2{*pX!qDz{}-VP1FAjtqES z*~v;>)x^QY&XCjEcBd~nX#SGeHp5?QoS*zV&}%chYikZ?QmOF6!K`5|h$eX)9wA1K zK3g4PBZ*!Q^U}y-r;E#CPFZrz5mI9Bg5QlQRk|6tZuCy}ZZaJQY#Dh|Zj4O#S*lGl zWYl#O@fXRCPXo!z8T)i00sPciZg8sp0Ed%i;jH?mzSANP`}|bU!eG5>Qj*fahylmP z;Ij|r^->QCxPN$PXJre`hXd#^YJAdTQ;UGb_g5veFgTYGj4lgF)3MsT22=qxqe~AFbQQLz^H3kD09O_vAdSos35CSIYN)uW%ypZ(}|UF>c&ht z;$BujZjX4z$bSkIzM zT_`Guxad+}4Ptt0w3Tqr`6pBZAMg0UK%Pp4q1XeQf@4Ff59V_{;2f9XjxuS|xNi;) zgwVN)fq}Xma^KNyk`FD5cP{&yM-^_VmssCiGR!fJie!ZNmT@#mgrwogvw^^p(C?RsWu;UXmx?Pus2o|`~D75Wns zU9bWgJCGxui@X^yK!7AwS7k}1Vl^6NRRgi*DSJsJvx&5|U@_79@geg{&^Hd|%mboK zhn;QS7Mf(>UzxYxQ$tz&ivi@a$u}1cA7?MKd0wcvZ0TqFOc1}d^|b;M)TsLSEzG2q zjQV%|ir8kBPEvto`??2i;M7k#Tt|AqI(aE!)cM81Us+Vw8a$~IBo!B)kqVV*3PROz z3JHzD_-#j) zerRV)L1+%f5+t!|w!a;_QR()Y*(ttXEm$9MD%g2{O;n7XrM-%4Fn-_+SBDM&oJ zfJgehiH=RytfB&KBrml7nU3nCWM>l54#Y6hGfMA{#OG(yb5b|yzA`Ko)ht1WpMl~t z5XIs*`=r->gl7gBGAMEGxHJ_ny+5K5KycZ9?lAgnpI@ybkMP7cx^~pg@cq*Eq3``o zr^2>GIV$RE0GM?OZ=FkL4~)Hub!!7^Z`a89iHRV$dFtrM1?UyJ1#~DLJF9HzlkI&r zc)j1<-7pyA_XTZ?ZIF88LFRU6zM7|JX}yNkJQg(*0W2-Btxn%B9&6|7(h8cV&?zBp zxEJ5?!>?z$@)IY$li1*7?avJbUIrIB=#uw6a4*{jg!C+$b{tG>JVwIWipjIHWBV#Q zgT8SsqZrNyZSPlnbr7CfFACC3QGDLSHh?M&3=!TLt4Vk>Y7wo?ehbx1vomsb6Du}5 zD!yc9h~0GBwtBNYtd94sVeIYP?T);EGy zUiAs01h8=G(|_J1ujPwEFl-!zb--wZPb^>O)sB3?g>7vdHJ3LWPN$?#2PBqMLBA{h zSRY(gVp=UIICF2AH*pl-6isiSW4#kG+^lKX6nky`oPWPWg-8Z&Up%-Rg`F>3=&;X| zMQ(fm+e1Wg^bI$<#&fUdU7ZEY*D5V-9S~RvJ?nU$l&n@yQDwaHM>GgaeZjIc(OltT z1aGNoOS7I9l72+SYp699>2HC{^LRHTccCn2o!?+h>1%|%jhEvYC9uu91P}eWs*5jk zjJjmUTtC6BSz`dE2v740sm7stCVYfSXx5FLq_Uz$ z*i5JBHy^pouk4eZ&3Ap-qDGY)4MHI>YzUsjofh1jpdhXZ6)?b|iOPt~`f7Kyfa=(E zdVU}n@kuP6IqZcaV+h490~f@FBXI3D$oy6}(S99a6JI3NT@z&suuP3pYJI5#QqWcg5M`H`t=Vp^Lai*?IbY^vsgXXQ9M$1`xUMrE9UMvz<8 zLh3jMDhdE(PVFAUQO&^(D98bNEo574-MK&j={DlD9UafR8P?k7^cHum(@KY(YU9OU zY?d{QglkHDJHh*jq~i{UvnP~VfL}Zp+C5WK9@%_(U5PxJNjz{=?xVp{swD`UkLF9_ z`mI-8hS{CAJ37#c`|)3o<0AH=C+#-7jpe5PvF_45ZdIirU<&M^p-4cXaN(Mt!>u-| z!U58gE#G%(-um{I@4Aa=sDU1kvNA3yCRfy;Zu?J~40&nknRF&kL#g^WH3tWe0nVCj zqDJgH^{bT~0kuUv_1yhB+q%NAwef5z7=?YOL4$Ec=-4A{?=V6aHVA-$Cd?&;U4kdm zDA7*FPmD4UxEK(C#iwk(v7cfOlUwF0wT;CJE`BjRPM=!7+?x}KF*?9Pvu1POZIR_Z z-a7CRPD(UmvLZ`610&Y^Gkr=05y__Z=W)8x?QW@P_aipo#~LjbMXM-Cl)w*}Z?m&+DhUR_zG$?K z_1)*1VQJf3lRHpkP19b@K-nql!}&A(*eaV=n;kn_l66`EVxUD?TnsO5Y;TdQs=ODo ztxo6c$!@>Z1@*mQ~Z!9XRnFZS|}8 zB2KD;0+c@Qvuf>g0DxXIA6*b*S@Q`IJ-GzI#+e)+?acBv+1o^Gq&%n0Jm~%yg0#s( zYmk8YjTXGaT*o+pytWJ;wLgV{s~}>?IGf+`m5RNAi2yDzeaNRAAB(oF`*n>r?uiRc z>$AZy%Xmq+GpnvJi5?kLPW@N_fP=-_Fi51_N(~#+OE@wZ1 zebac5fE%s~f_U%EPL&>85OM@h=EhRlw*N6SQ-h(LQF*IB=lbs?hf#S$C_xa0RboQ!!vJ)Kb>&{ zA_)sG>H@v)$>`vU^nG!iYc6FgI7QoD4*JmDvc@_KICwpAYb*X%6FuLg^<1XHIBI2{ z_C*y3V)@O8B1-6TD^y|8L`TCB!ZdX=+qSvJTNs*_y2O4YAdNrPkp3x@bT%=UW*i@~ zyj9A(SB;C;`%?uPHIWL@nI+g#A0k^dYcD{&Qq%M&K>t30tg~lI&s+b@NRNi`{cP--S(I-yu)7FersuisJc} z!1YeT$ml^%K535ITruK$F8j4Yl&h+=7+`r~{LVXl{w_7lM0;p3AF9rX?|nuQ%jW;&+I`Id;>&}^Y^$h$2>zt6sZ*|7N2O244$XCT^psIIWYTxl7rwfAA( z=0#6$R6%;o9}mSSnZe%Pn>&TewU6P4Hwg+;Jg4~nvRGP6Yf89LnIxIwGkR= z7kmoCEeBLFD>vM|@_d*c&+ikYg2)`BN{ayW46Fz#$go$33lvFcf-p2~s*JHSo@=6d z9%ipp&$o0M?*jB)Art4Hl1wH|?3>@!_atG+U{??baGAE5O#>j0-*;Q`Gl@GlwZgDX zPq_@kF|9#>fg68$i*8Fs2!OC>esY19%lpf{@ET5O{H?+R(A7X$dBLmMb4Ie!k?n?y zj`zggBW^IwEUeRRQuU27H7tFX{7wjE7I&Etmr*7dBD4!Fa(NxznLe#f!1uWQxu*dq zgU4JiI8*^ve=dq=W>Z*#-|wdiLZAmq!vTZPPZB)B7Y#O$vE}(g@yoQr{qe&otCvI0 z6kpq_9B8XqA5sx4^m2SKpFfrm5gCS$OU@;~`IUu>RH~w~IsWbK*yfz1Dtn2ls8jSg zL^SJQM-Qcs>zSENrMiu{G=~i?aUCgJAAg8PwFr0ieah7bW?QoEwM<+HWje^gjLrWZ z4qJnn0;064m~?85GpgARoPZKBW7lJA*E*;L5}G*}%n9ioi^{rzVE2{y0fP-eW9;o| z*=KL-Ux@z9O@mW;jh~)0HFjeMr(MpOuQM+P-{MOj6)Bgq9?VSgGg^rU#@Nu09J%QS zT&R2KKaPh$M-im1ZWWXVy`}9^_wlUwtZ^UZy~od%vJC2F=|~N4>+umx=xH@It?d zvuF_RRCQhAea_##@fZ;zJS$e?Uw;Sk|T{iLmD&_LIZat7c7RQM`bd}h&P%$kQTpWifeF@W79O%jB>%G+hGdm+k zWC25feq;FzD~laSdbwplLK1~cFr*KW>3orguTa|n5;i=)Gna){Vp6#mwCAa|BrHV!?cHnYp%GJ%?@j;w?tO#+)oZvv z!(Ly=Jced`1&w#=#?t8Cba-lIWVI2KAqoQ6ynYTSNZ`Zq7j3VCa{N`{P+Z}2LAFGR z${^CBVe zL!EfdJsPjD;^c{kOuR76!328xwycGhH)3Wb&;*{i05*1xAzD~iExpG-J!xv|QonI% z&s|3+l0e1qptR}Fyq+H_A&W1#@rN4gc1?Boyvg9QIE`3#?O9CZI5?+!+n*OU)tRv!;jt{CMmDA#-JWCjmh6>v z+jiYCR@I6H27N*E9Z^G)xK0hMXVlK!PIZLxGhC)xc*qAi%_*B>yR;dZBUZm*DLKe8 zr$??EiCb(*M%IAkQ8UOU#L#$)c1|{8Qirs2U3>7^irw8dq+v76LO4<}1kwbWMCFN3 z*yL~yi{a-E>yQFwp9kbvoYz8sj-SbYePT5R=;R>J-Cr~J6vn_6Ae2%6x^E&J2&|@3 z(DR^v@scqU3%3W%SO6Jiswk?1W4pxUN{GQGU?c?lZBD0m~7XCUcBQ! z%v8CzELLH--+u&$y*gYl95ObIeUf@;+Mg-;UMt}?)WvxPb>f9oF0brDrfmFY!<*sC z$;f>FCMDK_AP?r->wqwWhWFj~JiVT;E!GqJtv)J#+mpdjM#9t*cY^yhh>CL>gzS6gVLO=L{S)Q@Zc=G5v`yJghh#An z+Ovs-+|c^oxs&Imo@d*k_rvLJ&DFAxNP~u9T?ty`o#gzc322EFf{J-LA( z0SUg824_@b=kg1BU$Z70P5% z4qUOUBQvy1|A8r@yIkvX7=Fa~^TvSU5>fO+%3SG^m53FtNkWB6fGAHW(?^tpELZ%A zc{AsQ2+Psmnx1uKcUUd*3byEbBr?e)dHIw7I_uYOy>|8##mmUx0X_qIjcDv-F>cnH zDUH=*ShV_CJd*tdpG`nkSWN7eDgqX5T2@5t47GhE?VLCRlNyf`_`W4{7NRYwh9}p{ zX{w{2F$q)_xZCQsoyC@Xcp!MF{GBZQ_53oPuyKZ5A8&s>yq+^;&0!*O8CRC?*|tUp zc6@e5qhUTcU>09^zk#hWgeXS6NN zU(-k^ze!48uj$yuc@I-XPymH#saeS;J+UHt%I9J2LmJ^?Vh3o?Dy@#Gy&SlmCYW(u za%U`PK%$jNsZ@NtB2k53BYHrnrfZ1mqhr!NMkJT;0vb3qM) zN4?@igkNAS9`is*Uk6KFw5e$mnM^zr2>0KcoDbqfj5oRBuQLYSAeVkDjjy=ns-XP- zD^)uw4=K;iDb2GbJQEX#YP{2p#IlhlU{O{3g`@)O)fgs$TGe%@sA5JM&7978);K;y z$O>Pu!PH9g{?$Ird%{$C@O(LVn9!05K6|ax{NTO`ml+=S1?j5rsBDe7&?>M{6rGd_N;i&)NSaX+L+gv#HN*&o@EXux{)m zyUlzf=x2m>HxVjO!O!*S_K}ytm^a!zmRo<%cQv|ey_VMTa-m3V{U)4wm@R4^q|;a* z_rl~KBK60gv`OCcmX9?F=kHW%#UuIo8tNW+nJLcbkdm5vq(0IYIyhWQ9C zk7q7+)2$o{9L`!C`vaJ#*B_Wz4h3np|DRn0~cy8n-EWByQerkv40!Amw?D zHAB(&+S$75)p~6q{AfLOKDiwpT3O>Hzj)HVY}?U+a2DOasnPZhO@D!Jh&mR~@9@0p zMnv?E`x+KU0|Y!~WOeB|wmuaf?>2+!c**(+Jj{Ko3M#0JLqbBSRjdlB`PHp@yE}GSVF3Hy1QP>6IiD>KL71`d{F$6)%Bsus@c%o zd^w(R2L%4C${*Z3OG__(J@u>sm@Q{RCIurU^Wzr0e5qc0n;&wmm#!4HHER&N-q(UI z*EuD!JA~LhNKOtx6g7H$rN3omn5FNVq5&FtE+1dd9a<1(;s*dOSFLm2_>N0Rm9Eau zeREXYb&W;7t|0|-ISO(D^R2LzP2?=<$TLZ%~D%;}Yt6WV3 zhx65*oD5$D7sJ2JuZ@56^!jx8q%elGrVf+dObKBDn+mf{ALzh8<}vTiI%~e3=Z%@a zoK(EyAjvupJ@P%IzvW2~f4r?v>vsEX!_wbL%G$p z(?!?4fmL?A>1f`yXQ?8)n0jnu9+q*D7G5L3hHm2pS*v97~~Z~L{-GDB!+jx-s1 z7D%^|y>vN+eTj4OdIkDDyee*ghvO6>OSj-~ZTWnc( z2w~CTA4=1QzIJn7^Mp8GYy4@p5ep-BMBv?Z4-1+$soQw*J{GxYJLSndRML!~@#Nj^ z^*W&b!#*nJ_P47caETzi5<(miGC(mWjDXe_p%dsDoLHDJp9+mR6>LKY;=U6WE2N5j z8^%0(an9-4B*>t{XS{-ROqv^-Q0TY6a&~@zi7>q5h0zq4Cz1tZq10}9zu!5uyi9&3 z+7w{ehI=&Hn4e;Z`(2%!cQJ|fQ#{}2W-u7TJH2ZCZD!OvL`Z9SIajoNBf*Zr@!+*t z1K0EU72i-%@-=#&h5+lWG}j4+sZ2=l;HcO3@SM}E$ha%_p(qY1w?F7}b$H$`_*4Fd z2gPx^x%oShg)-|^3e=eL38{Qs7N4+^IKd*fr!+6q=R+0V;FF4?~s=H_*=9G zwDil1x7h~s0|2wlvJ$MghMH^okM$p#nrd3B_v9;u(rA>5c3+@n?g~1! zYle3FAtRk1oC24I?AcGVPkS~}-KSf7Pw@_YM{=8zuzw@8mODiM;ZK@0qa!)AfC?9z!)gkQ_#d2dPW;!h_vP1dSf}14%E#ghgCJEtqFG8Pxd zhvQ{|V8zo@a3GW{42kOGH12eZMcQ*$bV#CG;cjn5d%G6}MbS=w zB+zh(p>IL{q52{~gAJcWjaFi2#spmm6DChQ#nL=RazofQ5^jC^y(bV@mt$~53E0A3 zLD+s>wbG0`st{3@b~69$sO^A5DS5@uKAj7110rLxPny33wiY*}p>H>Rueq&Wk&c)xt!hv}ib$uys1n?{eXm$J5pu_0aats~qH8X>FU_UZ9g zq#T68FFO9SEBAfKo{uS}*?ds2X1@+geX;6aSL*6Ho&I~Ntx4Uo4gsZ8(2r}lsZW8< zH{^^C7oR`>y0?JxxWc?}-?zHa)cD@q@w@ z8O=P@?wyh!i@>y9{^#A>keXjOs($lJinHHVIY;2k;D%xiCzH+G;`SN`0ALS?#&{4| zG)(D_L?sG(bo<-heMsm;Q?ZoaaX75>-ES`XG%PU7d4EsElfmL5GxkgRVzKU76cr

eWH>CgV$trMb6d%Qw1e>1iEypi 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%0AGN?AcyN3e=)`^sfnG_SqKp@4C&VFi)Lo*^saL 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 literal 0 HcmV?d00001 diff --git a/site/content/en/images/cognito_pool_2.png b/site/content/en/images/cognito_pool_2.png new file mode 100644 index 0000000000000000000000000000000000000000..5e1a1b47dfe65600650ad0c228655a83f1adf6eb GIT binary patch literal 48508 zcmcG#Wl$XJ6FwRv3GQxTad)>QEN;QwT^D!QBm`L8-GaOOV!<7PI~&~HS>%#)&hKBh z>fSH+!=2i#otk}n-kzSP`|as|CishjBq|aS(u)@_P^G1S$}e6Zgui(4GUxrP=Of3^ zvd_;yhz?R(&M#h|b^raojAuk6e(~bN3u)jdRgbiTWl!C>%}3Ek-o@cRVJJV@O8tL+ zrDLJ&&6ZSlkRGN+QrJ2Z@2RA8+54y^`o$TZTSTgIV433drTVpaA)@r>z-&VR3*9S3 zjt?A3zdrOYb-id8eH-UX`sVV|=Vs^SwbSyf@AB+#-D9}+)ZYF+;totqb^mT}k-F*s-Fsp9^Z(DozYlz|E-x=z72o_fU51LawRN#fF|A`rNQg{P zZFcpUqibDhzC>2O#N{PK{l8%?E>lxeaYiqhndR;5&?0#uW3%JqtCvC@<$d_p);L%L zuY)%zg1WHY2cCK8;>pVDky1+jC#r9J3A9$#(4bA6RaIA)rUDO`I(;*Qd^G%-H+^`7 zsTZ!Y)U2VascCC%O@)DS*kSgc+#hNsm7FN4N2q(n-0*P!)KsC)wi{VNVR^#lR>-_< zeLw&`0O!B4zj3*_L+5bNsr^@K`d`2tk$=mZ7bZyb<)ef88#vHqtIAP;@$ zcw=jEPV@BEzuC!0(h;fXLdkq9Ge>d}g=|<&5AEf@L;p~p?I=x;q|Up!Q9`kbE#2{k z_pG(t+*rQ-#JT0)(?!1mX#;CdO0*ZhAin;#m8fO`isrK+fH7Gf0KGb=aQ~t zfn+^tL8E&`Qr9X9VRpe@*nji<8nW#FB8(Q~e5~)-7gUE?P*|jdgNKJ4K;s+B{_j~{ zcn~56&;$hZJYOeTgcYR%MYIqbH+Lsi?tlOP{H(2=lAkYuHQ@jJKc~X!mk$IB7ES*F z>}z7&M>bV}=Um52=5-ug+<5=Dj=z)thdxyWSy|`b9~8vJ&Ai{$hDl20;l6!)=bOg( z&$+*D>*)FVKAC^ilP?2v{ywIDQG9l=AM@>7o2qtJPM3`{XoyeTY}4emKZS4R|3n5@ z8_1p#RMORdnI=w2kxdWcJ}k*tSQR6_wV5NZ=1;0A(6VXgvHoIU;P^BCMd4sgg{%uW*VuvQ-|9#meirgZ?|V3@tq`uO2A0L<1zBWC+_@NpK9AnkrFHSclX7tKy5X z=*y}+;`PBe(a_Q7E30
_6EE< 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%0D|OOU1`<5l1X4RVk>p%ryb&zNF< zmmR|*BTjtAXOO2lbh6>gCoFhu)pP#UEIt1=r|m?}2!$gc43nWsIGY%D+^DB8@CpKLEqB)IW@c{NZu1YbZ|f2l7FWwSIDURU4+U@bpZ+-NIa&gZ&`ga~ z-)1w@x7zT)hKkiS)S7^z>tb66{%}^TT1~4DcSFNhz(Q|q4=z8~A)?bi{@Q5Xo9r_aG9>^p3y*}=dH=*^n-0tH%}0NDf* zk|f`VJzkHm`5$`u=l2r;o%Wx3)sd2%L|pIV&If;%w;iWL-R1kw`s^>--^O<@Z_m!Y z_B>d4jURc%K;qh_ZLxf*X2lrWAOG?RFFe;4o;oYYKx?xL zPyW`0d0~EuLHII5EnfoM-7dVL6I5-Ec%BY^(bnn;kGS1yLIUd(Xqzi8t6BJrm?VU| zh&#NLWZSr^6_km$Mkz%_R|$n~h0Aefo5zh2q}W;MdK?N3Fo&??gSC`8ykEt$=DEgT zGs^-{2yw&6i0uJcU*iPI3TQ-gi5B9UF^fmJc3Ln4<9=bU zl3O`k@G#6UNNl$SW-)U1`!$CFGmXzbeNlEhHJY>klouG`+2#ru5W5fbk(udeg4gYvr(#3m>P}&&pWRu!WM>>xxIqH4u^YlO` zY$3g}l0s*Nk>vPP5yc#y8id42;lLq`BxwWe7(|1c*#S>>0o!c65CNs9MBN@YGybU= zT~>B><7v!beDxj|<6unupHD~2{bpOW?#E^Uw|jjB;(CMf1y|h*E>?#NxL{4dl_xx~ z)b!5FOJ3=Mr~3$+AuyHs{c57tYrs|eH9)ZIWU+;xlVHG7Sh&Fuhg zGBJM_6zhHSMRxG*tt1ZmW!t!f4$oJX^tV;Lda#=m60gm|%M`upuKT#!t~X)me%nXJ zK40L7UbpkX$**BaRvmD#B8iNmbnxxZFv$D)n%A8Vci#(20q6IVzT0`Qg3If;56H|7VrvX@%*l4zmUxXieVM zn-g@fi7waaDIVgg?Rvm4n4#-!+#qP4o6YwE1txdhVd;kt7Vy@_+n_Hy_OE89&1EnC z5`pQF#Wp}2I`0fMMX+OFPYsOzTjP)D6t#v#ax7E5;F%u{{0ge#GQOoLKO|lTWa(5F z196}h{p7wR6zH+wiR)VvODb@T`IRdy@GXpNmh>lzC28SCwgx8xMh@n%7@j3;)f%hI zAYe1tVyoIWc=LN_3`x&9Pc9wIn?NJ!XpzUn+Sjs~J^ zt(G5_XGRJdc-MfKyc^)CLv&Xnw1i@0;4B%mgAMI%0-h^YQNe9RP!gRjvC%Npk3LU|mbOuvs4*th+h z=GDJvY_e^$8ZXk&bsPNI4@kK;yc`HLXt_U80b{*~i5BoXYA>Gh0W9oE+HZ0MElMI^ zyxoB6|B9@Z^M06=CBvg+(P1295}vx2=UM)FCV`h)6ry*pBieCLb;9JlE=kPwKD&O* zV-p5e9ubp?3mIwGX1yDOLge?b%d6kn0gtM_;s@N(^KXGtS-*PjAMDS0J=m4RLH2*o z_TJ(YtZTC?AI9$H3rSl|E4|$-Krjio#leo|hh){5>{*+um7GAb(#Y2T0_L%w75e!+ z6PPjHZ^P!kE?*Dku-7YUQ5{xDk)ub**-nz^M9AoCYl#T9eBFM&5AeB*!MXCgyqm-C zQER_?p7iA{GJGX^19W>rF$pyOoaXm`?}3yg@z^8A-X z1yOdOqWm3Uy93&VIhJ^aYOgjQwU_fADU`H_)lMt{7>pD=l3K{ph$x0+!> z|41Ch8r+ZvZ~mf?7&_?a+AKAB+EU8VPD81Ny})YCV;c=I2>aq>QAih$)-J^AB;>E0 z8eC9Io3N`=^>?z19u@-EfP&=*Qf7F8GbAC=k|rq$c=hB6lU6qDL?g{bBXv8a@01EP zbTvc0v=UQAW^`8IWPz_}Fiv8)(cY1VXwyUoLKSscIMF%O&cEA5S!qVUEHaCD;z`M* z{xv2h5-~WVTM{b>OFSl5v+$$kfrY^Z7KC&21f^5rz;TIYwE

INwuU_vrmSj`*A3 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 literal 0 HcmV?d00001 From b1d0a6cf91b16b89f8024480e9297eec545c7679 Mon Sep 17 00:00:00 2001 From: Roman Donchenko Date: Wed, 25 Dec 2024 18:04:24 +0200 Subject: [PATCH 12/31] CLI: Add ability to create CVAT agents based on AA functions (#8821) --- .../20241212_193004_roman_cli_agent.md | 4 + cvat-cli/README.md | 5 + cvat-cli/requirements/base.txt | 4 +- cvat-cli/src/cvat_cli/__main__.py | 9 +- cvat-cli/src/cvat_cli/_internal/agent.py | 351 ++++++++++++++++++ .../src/cvat_cli/_internal/commands_all.py | 2 + .../cvat_cli/_internal/commands_functions.py | 138 +++++++ .../src/cvat_cli/_internal/commands_tasks.py | 59 +-- cvat-cli/src/cvat_cli/_internal/common.py | 85 +++++ cvat-sdk/cvat_sdk/auto_annotation/driver.py | 94 +++-- dev/update_version.py | 4 +- site/content/en/docs/api_sdk/cli/_index.md | 37 ++ 12 files changed, 710 insertions(+), 82 deletions(-) create mode 100644 changelog.d/20241212_193004_roman_cli_agent.md create mode 100644 cvat-cli/src/cvat_cli/_internal/agent.py create mode 100644 cvat-cli/src/cvat_cli/_internal/commands_functions.py diff --git a/changelog.d/20241212_193004_roman_cli_agent.md b/changelog.d/20241212_193004_roman_cli_agent.md new file mode 100644 index 000000000000..f7fd8c0a5be4 --- /dev/null +++ b/changelog.d/20241212_193004_roman_cli_agent.md @@ -0,0 +1,4 @@ +### Added + +- \[CLI\] Added commands for working with native functions + () diff --git a/cvat-cli/README.md b/cvat-cli/README.md index bbd98c0980c9..fcee05dae1c4 100644 --- a/cvat-cli/README.md +++ b/cvat-cli/README.md @@ -22,6 +22,11 @@ The following subcommands are supported: - `backup` - back up a task - `auto-annotate` - automatically annotate a task using a local function +- Functions (Enterprise/Cloud only): + - `create-native` - create a function that can be powered by an agent + - `delete` - delete a function + - `run-agent` - process requests for a native function + ## Installation `pip install cvat-cli` diff --git a/cvat-cli/requirements/base.txt b/cvat-cli/requirements/base.txt index 94b064e0ace5..a53fd13b472e 100644 --- a/cvat-cli/requirements/base.txt +++ b/cvat-cli/requirements/base.txt @@ -1,3 +1,5 @@ -cvat-sdk~=2.24.1 +cvat-sdk==2.24.1 + +attrs>=24.2.0 Pillow>=10.3.0 setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability diff --git a/cvat-cli/src/cvat_cli/__main__.py b/cvat-cli/src/cvat_cli/__main__.py index c93569182c08..7c649747cb31 100755 --- a/cvat-cli/src/cvat_cli/__main__.py +++ b/cvat-cli/src/cvat_cli/__main__.py @@ -11,7 +11,12 @@ from cvat_sdk import exceptions from ._internal.commands_all import COMMANDS -from ._internal.common import build_client, configure_common_arguments, configure_logger +from ._internal.common import ( + CriticalError, + build_client, + configure_common_arguments, + configure_logger, +) from ._internal.utils import popattr logger = logging.getLogger(__name__) @@ -29,7 +34,7 @@ def main(args: list[str] = None): try: with build_client(parsed_args, logger=logger) as client: popattr(parsed_args, "_executor")(client, **vars(parsed_args)) - except (exceptions.ApiException, urllib3.exceptions.HTTPError) as e: + except (exceptions.ApiException, urllib3.exceptions.HTTPError, CriticalError) as e: logger.critical(e) return 1 diff --git a/cvat-cli/src/cvat_cli/_internal/agent.py b/cvat-cli/src/cvat_cli/_internal/agent.py new file mode 100644 index 000000000000..820a758e54d2 --- /dev/null +++ b/cvat-cli/src/cvat_cli/_internal/agent.py @@ -0,0 +1,351 @@ +# Copyright (C) 2024 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +import concurrent.futures +import json +import multiprocessing +import random +import secrets +import shutil +import tempfile +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Optional + +import cvat_sdk.auto_annotation as cvataa +import cvat_sdk.datasets as cvatds +import urllib3.exceptions +from cvat_sdk import Client, models +from cvat_sdk.auto_annotation.driver import ( + _AnnotationMapper, + _DetectionFunctionContextImpl, + _LabelNameMapping, + _SpecNameMapping, +) +from cvat_sdk.exceptions import ApiException + +from .common import CriticalError, FunctionLoader + +FUNCTION_PROVIDER_NATIVE = "native" +FUNCTION_KIND_DETECTOR = "detector" + +_POLLING_INTERVAL_MEAN = timedelta(seconds=60) +_POLLING_INTERVAL_MAX_OFFSET = timedelta(seconds=10) + +_UPDATE_INTERVAL = timedelta(seconds=30) + + +class _RecoverableExecutor: + # A wrapper around ProcessPoolExecutor that recreates the underlying + # executor when a worker crashes. + def __init__(self, initializer, initargs): + self._mp_context = multiprocessing.get_context("spawn") + self._initializer = initializer + self._initargs = initargs + self._executor = self._new_executor() + + def _new_executor(self): + return concurrent.futures.ProcessPoolExecutor( + max_workers=1, + mp_context=self._mp_context, + initializer=self._initializer, + initargs=self._initargs, + ) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self._executor.shutdown() + + def submit(self, func, /, *args, **kwargs): + return self._executor.submit(func, *args, **kwargs) + + def result(self, future: concurrent.futures.Future): + try: + return future.result() + except concurrent.futures.BrokenExecutor: + self._executor.shutdown() + self._executor = self._new_executor() + raise + + +_current_function: cvataa.DetectionFunction + + +def _worker_init(function_loader: FunctionLoader): + global _current_function + _current_function = function_loader.load() + + +def _worker_job_get_function_spec(): + return _current_function.spec + + +def _worker_job_detect(context, image): + return _current_function.detect(context, image) + + +class _Agent: + def __init__(self, client: Client, executor: _RecoverableExecutor, function_id: int): + self._rng = random.Random() # nosec + + self._client = client + self._executor = executor + self._function_id = function_id + self._function_spec = self._executor.result( + self._executor.submit(_worker_job_get_function_spec) + ) + + _, response = self._client.api_client.call_api( + "/api/functions/{function_id}", + "GET", + path_params={"function_id": self._function_id}, + ) + + remote_function = json.loads(response.data) + + self._validate_function_compatibility(remote_function) + + self._agent_id = secrets.token_hex(16) + self._client.logger.info("Agent starting with ID %r", self._agent_id) + + self._cached_task_id = None + + def _validate_function_compatibility(self, remote_function: dict) -> None: + function_id = remote_function["id"] + + if remote_function["provider"] != FUNCTION_PROVIDER_NATIVE: + raise CriticalError( + f"Function #{function_id} has provider {remote_function['provider']!r}. " + f"Agents can only be run for functions with provider {FUNCTION_PROVIDER_NATIVE!r}." + ) + + if isinstance(self._function_spec, cvataa.DetectionFunctionSpec): + self._validate_detection_function_compatibility(remote_function) + self._calculate_result_for_ar = self._calculate_result_for_detection_ar + else: + raise CriticalError( + f"Unsupported function spec type: {type(self._function_spec).__name__}" + ) + + def _validate_detection_function_compatibility(self, remote_function: dict) -> None: + incompatible_msg = ( + f"Function #{remote_function['id']} is incompatible with function object: " + ) + + if remote_function["kind"] != FUNCTION_KIND_DETECTOR: + raise CriticalError( + incompatible_msg + + f"kind is {remote_function['kind']!r} (expected {FUNCTION_KIND_DETECTOR!r})." + ) + + labels_by_name = {label.name: label for label in self._function_spec.labels} + + for remote_label in remote_function["labels_v2"]: + label = labels_by_name.get(remote_label["name"]) + + if not label: + raise CriticalError( + incompatible_msg + f"label {remote_label['name']!r} is not supported." + ) + + if ( + remote_label["type"] not in {"any", "unknown"} + and remote_label["type"] != label.type + ): + raise CriticalError( + incompatible_msg + + f"label {remote_label['name']!r} has type {remote_label['type']!r}, " + f"but the function object expects type {label.type!r}." + ) + + if remote_label["attributes"]: + raise CriticalError( + incompatible_msg + + f"label {remote_label['name']!r} has attributes, which is not supported." + ) + + def _wait_between_polls(self): + # offset the interval randomly to avoid synchronization between workers + max_offset_sec = _POLLING_INTERVAL_MAX_OFFSET.total_seconds() + offset_sec = self._rng.uniform(-max_offset_sec, max_offset_sec) + time.sleep(_POLLING_INTERVAL_MEAN.total_seconds() + offset_sec) + + def run(self, *, burst: bool) -> None: + if burst: + while ar_assignment := self._poll_for_ar(): + self._process_ar(ar_assignment) + self._client.logger.info("No annotation requests left in queue; exiting.") + else: + while True: + if ar_assignment := self._poll_for_ar(): + self._process_ar(ar_assignment) + else: + self._wait_between_polls() + + def _process_ar(self, ar_assignment: dict) -> None: + self._client.logger.info("Got annotation request assignment: %r", ar_assignment) + + ar_id = ar_assignment["ar_id"] + + try: + result = self._calculate_result_for_ar(ar_id, ar_assignment["ar_params"]) + + self._client.logger.info("Submitting result for AR %r...", ar_id) + self._client.api_client.call_api( + "/api/functions/queues/{queue_id}/requests/{request_id}/complete", + "POST", + path_params={"queue_id": f"function:{self._function_id}", "request_id": ar_id}, + body={"agent_id": self._agent_id, "annotations": result}, + ) + self._client.logger.info("AR %r completed", ar_id) + except Exception as ex: + self._client.logger.error("Failed to process AR %r", ar_id, exc_info=True) + + # Arbitrary exceptions may contain details of the client's system or code, which + # shouldn't be exposed to the server (and to users of the function). + # Therefore, we only produce a limited amount of detail, and only in known failure cases. + error_message = "Unknown error" + + if isinstance(ex, ApiException): + if ex.status: + error_message = f"Received HTTP status {ex.status}" + else: + error_message = "Failed an API call" + elif isinstance(ex, urllib3.exceptions.RequestError): + if isinstance(ex, urllib3.exceptions.MaxRetryError): + ex_type = type(ex.reason) + else: + ex_type = type(ex) + + error_message = f"Failed to make an HTTP request to {ex.url} ({ex_type.__name__})" + elif isinstance(ex, urllib3.exceptions.HTTPError): + error_message = "Failed to make an HTTP request" + elif isinstance(ex, cvataa.BadFunctionError): + error_message = "Underlying function returned incorrect result: " + str(ex) + elif isinstance(ex, concurrent.futures.BrokenExecutor): + error_message = "Worker process crashed" + + try: + self._client.api_client.call_api( + "/api/functions/queues/{queue_id}/requests/{request_id}/fail", + "POST", + path_params={ + "queue_id": f"function:{self._function_id}", + "request_id": ar_id, + }, + body={"agent_id": self._agent_id, "exc_info": error_message}, + ) + except Exception: + self._client.logger.error("Couldn't fail AR %r", ar_id, exc_info=True) + else: + self._client.logger.info("AR %r failed", ar_id) + + def _poll_for_ar(self) -> Optional[dict]: + while True: + self._client.logger.info("Trying to acquire an annotation request...") + try: + _, response = self._client.api_client.call_api( + "/api/functions/queues/{queue_id}/requests/acquire", + "POST", + path_params={"queue_id": f"function:{self._function_id}"}, + body={"agent_id": self._agent_id, "request_category": "batch"}, + ) + break + except (urllib3.exceptions.HTTPError, ApiException) as ex: + if isinstance(ex, ApiException) and ex.status and 400 <= ex.status < 500: + # We did something wrong; no point in retrying. + raise + + self._client.logger.error("Acquire request failed; will retry", exc_info=True) + self._wait_between_polls() + + response_data = json.loads(response.data) + return response_data["ar_assignment"] + + def _calculate_result_for_detection_ar( + self, ar_id: str, ar_params + ) -> models.PatchedLabeledDataRequest: + if ar_params["type"] != "annotate_task": + raise RuntimeError(f"Unsupported AR type: {ar_params['type']!r}") + + if ar_params["task"] != self._cached_task_id: + # To avoid uncontrolled disk usage, + # we'll only keep one task in the cache at a time. + self._client.logger.info("Switched to a new task; clearing the cache...") + if self._client.config.cache_dir.exists(): + shutil.rmtree(self._client.config.cache_dir) + + ds = cvatds.TaskDataset(self._client, ar_params["task"], load_annotations=False) + + self._cached_task_id = ar_params["task"] + + # Fetching the dataset might take a while, so do a progress update to let the server + # know we're still alive. + self._update_ar(ar_id, 0) + last_update_timestamp = datetime.now(tz=timezone.utc) + + mapping = ar_params["mapping"] + conv_mask_to_poly = ar_params["conv_mask_to_poly"] + + spec_nm = _SpecNameMapping( + labels={k: _LabelNameMapping(v["name"]) for k, v in mapping.items()} + ) + + mapper = _AnnotationMapper( + self._client.logger, + self._function_spec.labels, + ds.labels, + allow_unmatched_labels=False, + spec_nm=spec_nm, + conv_mask_to_poly=conv_mask_to_poly, + ) + + all_annotations = models.PatchedLabeledDataRequest(shapes=[]) + + for sample_index, sample in enumerate(ds.samples): + context = _DetectionFunctionContextImpl( + frame_name=sample.frame_name, + conf_threshold=ar_params["threshold"], + conv_mask_to_poly=conv_mask_to_poly, + ) + shapes = self._executor.result( + self._executor.submit(_worker_job_detect, context, sample.media.load_image()) + ) + + mapper.validate_and_remap(shapes, sample.frame_index) + all_annotations.shapes.extend(shapes) + + current_timestamp = datetime.now(tz=timezone.utc) + + if current_timestamp >= last_update_timestamp + _UPDATE_INTERVAL: + self._update_ar(ar_id, (sample_index + 1) / len(ds.samples)) + last_update_timestamp = current_timestamp + + return all_annotations + + def _update_ar(self, ar_id: str, progress: float) -> None: + self._client.logger.info("Updating AR %r progress to %.2f%%", ar_id, progress * 100) + self._client.api_client.call_api( + "/api/functions/queues/{queue_id}/requests/{request_id}/update", + "POST", + path_params={"queue_id": f"function:{self._function_id}", "request_id": ar_id}, + body={"agent_id": self._agent_id, "progress": progress}, + ) + + +def run_agent( + client: Client, function_loader: FunctionLoader, function_id: int, *, burst: bool +) -> None: + with ( + _RecoverableExecutor(initializer=_worker_init, initargs=[function_loader]) as executor, + tempfile.TemporaryDirectory() as cache_dir, + ): + client.config.cache_dir = Path(cache_dir, "cache") + client.logger.info("Will store cache at %s", client.config.cache_dir) + + agent = _Agent(client, executor, function_id) + agent.run(burst=burst) diff --git a/cvat-cli/src/cvat_cli/_internal/commands_all.py b/cvat-cli/src/cvat_cli/_internal/commands_all.py index 758d6b1d05e8..5f293f0ce06f 100644 --- a/cvat-cli/src/cvat_cli/_internal/commands_all.py +++ b/cvat-cli/src/cvat_cli/_internal/commands_all.py @@ -3,11 +3,13 @@ # SPDX-License-Identifier: MIT from .command_base import CommandGroup, DeprecatedAlias +from .commands_functions import COMMANDS as COMMANDS_FUNCTIONS from .commands_projects import COMMANDS as COMMANDS_PROJECTS from .commands_tasks import COMMANDS as COMMANDS_TASKS COMMANDS = CommandGroup(description="Perform operations on CVAT resources.") +COMMANDS.add_command("function", COMMANDS_FUNCTIONS) COMMANDS.add_command("project", COMMANDS_PROJECTS) COMMANDS.add_command("task", COMMANDS_TASKS) diff --git a/cvat-cli/src/cvat_cli/_internal/commands_functions.py b/cvat-cli/src/cvat_cli/_internal/commands_functions.py new file mode 100644 index 000000000000..76ccc56b05e9 --- /dev/null +++ b/cvat-cli/src/cvat_cli/_internal/commands_functions.py @@ -0,0 +1,138 @@ +# Copyright (C) 2024 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +import argparse +import json +import textwrap +from collections.abc import Sequence + +import cvat_sdk.auto_annotation as cvataa +from cvat_sdk import Client + +from .agent import FUNCTION_KIND_DETECTOR, FUNCTION_PROVIDER_NATIVE, run_agent +from .command_base import CommandGroup +from .common import FunctionLoader, configure_function_implementation_arguments + +COMMANDS = CommandGroup(description="Perform operations on CVAT lambda functions.") + + +@COMMANDS.command_class("create-native") +class FunctionCreateNative: + description = textwrap.dedent( + """\ + Create a CVAT function that can be powered by an agent running the given local function. + """ + ) + + def configure_parser(self, parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "name", + help="a human-readable name for the function", + ) + + configure_function_implementation_arguments(parser) + + def execute( + self, + client: Client, + *, + name: str, + function_loader: FunctionLoader, + ) -> None: + function = function_loader.load() + + remote_function = { + "provider": FUNCTION_PROVIDER_NATIVE, + "name": name, + } + + if isinstance(function.spec, cvataa.DetectionFunctionSpec): + remote_function["kind"] = FUNCTION_KIND_DETECTOR + remote_function["labels_v2"] = [] + + for label_spec in function.spec.labels: + if getattr(label_spec, "sublabels", None): + raise cvataa.BadFunctionError( + f"Function label {label_spec.name!r} has sublabels. This is currently not supported." + ) + + remote_function["labels_v2"].append( + { + "name": label_spec.name, + } + ) + else: + raise cvataa.BadFunctionError( + f"Unsupported function spec type: {type(function.spec).__name__}" + ) + + _, response = client.api_client.call_api( + "/api/functions", + "POST", + body=remote_function, + ) + + remote_function = json.loads(response.data) + + client.logger.info( + "Created function #%d: %s", remote_function["id"], remote_function["name"] + ) + print(remote_function["id"]) + + +@COMMANDS.command_class("delete") +class FunctionDelete: + description = "Delete a list of functions, ignoring those which don't exist." + + def configure_parser(self, parser: argparse.ArgumentParser) -> None: + parser.add_argument("function_ids", type=int, help="IDs of functions to delete", nargs="+") + + def execute(self, client: Client, *, function_ids: Sequence[int]) -> None: + for function_id in function_ids: + _, response = client.api_client.call_api( + "/api/functions/{function_id}", + "DELETE", + path_params={"function_id": function_id}, + _check_status=False, + ) + + if 200 <= response.status <= 299: + client.logger.info(f"Function #{function_id} deleted") + elif response.status == 404: + client.logger.warning(f"Function #{function_id} not found") + else: + client.logger.error( + f"Failed to delete function #{function_id}: " + f"{response.msg} (status {response.status})" + ) + + +@COMMANDS.command_class("run-agent") +class FunctionRunAgent: + description = "Process requests for a given native function, indefinitely." + + def configure_parser(self, parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "function_id", + type=int, + help="ID of the function to process requests for", + ) + + configure_function_implementation_arguments(parser) + + parser.add_argument( + "--burst", + action="store_true", + help="process all pending requests and then exit", + ) + + def execute( + self, + client: Client, + *, + function_id: int, + function_loader: FunctionLoader, + burst: bool, + ) -> None: + run_agent(client, function_loader, function_id, burst=burst) diff --git a/cvat-cli/src/cvat_cli/_internal/commands_tasks.py b/cvat-cli/src/cvat_cli/_internal/commands_tasks.py index 8c6782887d97..cbe2139cf457 100644 --- a/cvat-cli/src/cvat_cli/_internal/commands_tasks.py +++ b/cvat-cli/src/cvat_cli/_internal/commands_tasks.py @@ -5,12 +5,9 @@ from __future__ import annotations import argparse -import importlib -import importlib.util import textwrap from collections.abc import Sequence -from pathlib import Path -from typing import Any, Optional +from typing import Optional import cvat_sdk.auto_annotation as cvataa from attr.converters import to_bool @@ -19,13 +16,8 @@ from cvat_sdk.core.proxies.tasks import ResourceType from .command_base import CommandGroup, GenericCommand, GenericDeleteCommand, GenericListCommand -from .parsers import ( - BuildDictAction, - parse_function_parameter, - parse_label_arg, - parse_resource_type, - parse_threshold, -) +from .common import FunctionLoader, configure_function_implementation_arguments +from .parsers import parse_label_arg, parse_resource_type, parse_threshold COMMANDS = CommandGroup(description="Perform operations on CVAT tasks.") @@ -416,30 +408,7 @@ class TaskAutoAnnotate: def configure_parser(self, parser: argparse.ArgumentParser) -> None: parser.add_argument("task_id", type=int, help="task ID") - function_group = parser.add_mutually_exclusive_group(required=True) - - function_group.add_argument( - "--function-module", - metavar="MODULE", - help="qualified name of a module to use as the function", - ) - - function_group.add_argument( - "--function-file", - metavar="PATH", - type=Path, - help="path to a Python source file to use as the function", - ) - - parser.add_argument( - "--function-parameter", - "-p", - metavar="NAME=TYPE:VALUE", - type=parse_function_parameter, - action=BuildDictAction, - dest="function_parameters", - help="parameter for the function", - ) + configure_function_implementation_arguments(parser) parser.add_argument( "--clear-existing", @@ -471,29 +440,13 @@ def execute( client: Client, *, task_id: int, - function_module: Optional[str] = None, - function_file: Optional[Path] = None, - function_parameters: dict[str, Any], + function_loader: FunctionLoader, clear_existing: bool = False, allow_unmatched_labels: bool = False, conf_threshold: Optional[float], conv_mask_to_poly: bool, ) -> None: - if function_module is not None: - function = importlib.import_module(function_module) - elif function_file is not None: - module_spec = importlib.util.spec_from_file_location("__cvat_function__", function_file) - function = importlib.util.module_from_spec(module_spec) - module_spec.loader.exec_module(function) - else: - assert False, "function identification arguments missing" - - if hasattr(function, "create"): - # this is actually a function factory - function = function.create(**function_parameters) - else: - if function_parameters: - raise TypeError("function takes no parameters") + function = function_loader.load() cvataa.annotate_task( client, diff --git a/cvat-cli/src/cvat_cli/_internal/common.py b/cvat-cli/src/cvat_cli/_internal/common.py index 6f37e3d74eaa..e07d85c9b65e 100644 --- a/cvat-cli/src/cvat_cli/_internal/common.py +++ b/cvat-cli/src/cvat_cli/_internal/common.py @@ -5,17 +5,28 @@ import argparse import getpass +import importlib +import importlib.util import logging import os import sys from http.client import HTTPConnection +from pathlib import Path +from typing import Any, Optional +import attrs +import cvat_sdk.auto_annotation as cvataa from cvat_sdk.core.client import Client, Config from ..version import VERSION +from .parsers import BuildDictAction, parse_function_parameter from .utils import popattr +class CriticalError(Exception): + pass + + def get_auth(s): """Parse USER[:PASS] strings and prompt for password if none was supplied.""" @@ -102,3 +113,77 @@ def build_client(parsed_args: argparse.Namespace, logger: logging.Logger) -> Cli client.organization_slug = popattr(parsed_args, "organization") return client + + +def configure_function_implementation_arguments(parser: argparse.ArgumentParser) -> None: + function_group = parser.add_mutually_exclusive_group(required=True) + + function_group.add_argument( + "--function-module", + metavar="MODULE", + help="qualified name of a module to use as the function", + ) + + function_group.add_argument( + "--function-file", + metavar="PATH", + type=Path, + help="path to a Python source file to use as the function", + ) + + parser.add_argument( + "--function-parameter", + "-p", + metavar="NAME=TYPE:VALUE", + type=parse_function_parameter, + action=BuildDictAction, + dest="function_parameters", + help="parameter for the function", + ) + + original_executor = parser.get_default("_executor") + + def execute_with_function_loader( + client, + *, + function_module: Optional[str], + function_file: Optional[Path], + function_parameters: dict[str, Any], + **kwargs, + ): + original_executor( + client, + function_loader=FunctionLoader(function_module, function_file, function_parameters), + **kwargs, + ) + + parser.set_defaults(_executor=execute_with_function_loader) + + +@attrs.frozen +class FunctionLoader: + function_module: Optional[str] + function_file: Optional[Path] + function_parameters: dict[str, Any] + + def __attrs_post_init__(self): + assert self.function_module is not None or self.function_file is not None + + def load(self) -> cvataa.DetectionFunction: + if self.function_module is not None: + function = importlib.import_module(self.function_module) + else: + module_spec = importlib.util.spec_from_file_location( + "__cvat_function__", self.function_file + ) + function = importlib.util.module_from_spec(module_spec) + module_spec.loader.exec_module(function) + + if hasattr(function, "create"): + # this is actually a function factory + function = function.create(**self.function_parameters) + else: + if self.function_parameters: + raise TypeError("function takes no parameters") + + return function diff --git a/cvat-sdk/cvat_sdk/auto_annotation/driver.py b/cvat-sdk/cvat_sdk/auto_annotation/driver.py index 5ffdb36f5bee..42e17f93b6b2 100644 --- a/cvat-sdk/cvat_sdk/auto_annotation/driver.py +++ b/cvat-sdk/cvat_sdk/auto_annotation/driver.py @@ -23,24 +23,62 @@ class BadFunctionError(Exception): """ +@attrs.frozen +class _SublabelNameMapping: + name: str + + +@attrs.frozen +class _LabelNameMapping(_SublabelNameMapping): + sublabels: Optional[Mapping[str, _SublabelNameMapping]] = attrs.field( + kw_only=True, default=None + ) + + def map_sublabel(self, name: str): + if self.sublabels is None: + return _SublabelNameMapping(name) + + return self.sublabels.get(name) + + +@attrs.frozen +class _SpecNameMapping: + labels: Optional[Mapping[str, _LabelNameMapping]] = attrs.field(kw_only=True, default=None) + + def map_label(self, name: str): + if self.labels is None: + return _LabelNameMapping(name) + + return self.labels.get(name) + + class _AnnotationMapper: @attrs.frozen - class _MappedLabel: + class _LabelIdMapping: id: int - sublabel_mapping: Mapping[int, Optional[int]] + sublabels: Mapping[int, Optional[int]] expected_num_elements: int = 0 - _label_mapping: Mapping[int, Optional[_MappedLabel]] + _label_id_mappings: Mapping[int, Optional[_LabelIdMapping]] - def _build_mapped_label( - self, fun_label: models.ILabel, ds_labels_by_name: Mapping[str, models.ILabel] - ) -> Optional[_MappedLabel]: + def _build_label_id_mapping( + self, + fun_label: models.ILabel, + ds_labels_by_name: Mapping[str, models.ILabel], + *, + allow_unmatched_labels: bool, + spec_nm: _SpecNameMapping, + ) -> Optional[_LabelIdMapping]: if getattr(fun_label, "attributes", None): raise BadFunctionError(f"label attributes are currently not supported") - ds_label = ds_labels_by_name.get(fun_label.name) + label_nm = spec_nm.map_label(fun_label.name) + if label_nm is None: + return None + + ds_label = ds_labels_by_name.get(label_nm.name) if ds_label is None: - if not self._allow_unmatched_labels: + if not allow_unmatched_labels: raise BadFunctionError(f"label {fun_label.name!r} is not in dataset") self._logger.info( @@ -71,9 +109,14 @@ def _build_mapped_label( f"sublabel {fun_sl.name!r} of label {fun_label.name!r} has same ID as another sublabel ({fun_sl.id})" ) - ds_sl = ds_sublabels_by_name.get(fun_sl.name) + sublabel_nm = label_nm.map_sublabel(fun_sl.name) + if sublabel_nm is None: + sl_map[fun_sl.id] = None + continue + + ds_sl = ds_sublabels_by_name.get(sublabel_nm.name) if not ds_sl: - if not self._allow_unmatched_labels: + if not allow_unmatched_labels: raise BadFunctionError( f"sublabel {fun_sl.name!r} of label {fun_label.name!r} is not in dataset" ) @@ -88,8 +131,8 @@ def _build_mapped_label( sl_map[fun_sl.id] = ds_sl.id - return self._MappedLabel( - ds_label.id, sublabel_mapping=sl_map, expected_num_elements=len(ds_label.sublabels) + return self._LabelIdMapping( + ds_label.id, sublabels=sl_map, expected_num_elements=len(ds_label.sublabels) ) def __init__( @@ -100,26 +143,29 @@ def __init__( *, allow_unmatched_labels: bool, conv_mask_to_poly: bool, + spec_nm: _SpecNameMapping = _SpecNameMapping(), ) -> None: self._logger = logger - self._allow_unmatched_labels = allow_unmatched_labels self._conv_mask_to_poly = conv_mask_to_poly ds_labels_by_name = {ds_label.name: ds_label for ds_label in ds_labels} - self._label_mapping = {} + self._label_id_mappings = {} for fun_label in fun_labels: if not hasattr(fun_label, "id"): raise BadFunctionError(f"label {fun_label.name!r} has no ID") - if fun_label.id in self._label_mapping: + if fun_label.id in self._label_id_mappings: raise BadFunctionError( f"label {fun_label.name} has same ID as another label ({fun_label.id})" ) - self._label_mapping[fun_label.id] = self._build_mapped_label( - fun_label, ds_labels_by_name + self._label_id_mappings[fun_label.id] = self._build_label_id_mapping( + fun_label, + ds_labels_by_name, + allow_unmatched_labels=allow_unmatched_labels, + spec_nm=spec_nm, ) def validate_and_remap(self, shapes: list[models.LabeledShapeRequest], ds_frame: int) -> None: @@ -141,16 +187,16 @@ def validate_and_remap(self, shapes: list[models.LabeledShapeRequest], ds_frame: shape.frame = ds_frame try: - mapped_label = self._label_mapping[shape.label_id] + label_id_mapping = self._label_id_mappings[shape.label_id] except KeyError: raise BadFunctionError( f"function output shape with unknown label ID ({shape.label_id})" ) - if not mapped_label: + if not label_id_mapping: continue - shape.label_id = mapped_label.id + shape.label_id = label_id_mapping.id if getattr(shape, "attributes", None): raise BadFunctionError( @@ -184,7 +230,7 @@ def validate_and_remap(self, shapes: list[models.LabeledShapeRequest], ds_frame: ) try: - mapped_sl_id = mapped_label.sublabel_mapping[element.label_id] + mapped_sl_id = label_id_mapping.sublabels[element.label_id] except KeyError: raise BadFunctionError( f"function output shape with unknown sublabel ID ({element.label_id})" @@ -204,14 +250,14 @@ def validate_and_remap(self, shapes: list[models.LabeledShapeRequest], ds_frame: new_elements.append(element) - if len(new_elements) != mapped_label.expected_num_elements: + if len(new_elements) != label_id_mapping.expected_num_elements: # new_elements could only be shorter than expected, # because the reverse would imply that there are more distinct sublabel IDs # than are actually defined in the dataset. - assert len(new_elements) < mapped_label.expected_num_elements + assert len(new_elements) < label_id_mapping.expected_num_elements raise BadFunctionError( - f"function output skeleton with fewer elements than expected ({len(new_elements)} vs {mapped_label.expected_num_elements})" + f"function output skeleton with fewer elements than expected ({len(new_elements)} vs {label_id_mapping.expected_num_elements})" ) shape.elements[:] = new_elements diff --git a/dev/update_version.py b/dev/update_version.py index bc175aa16dd0..fbe5da9971c0 100755 --- a/dev/update_version.py +++ b/dev/update_version.py @@ -160,8 +160,8 @@ def apply(self, new_version: Version, *, verify_only: bool) -> bool: ), ReplacementRule( "cvat-cli/requirements/base.txt", - re.compile(r"^cvat-sdk~=[\d.]+$", re.M), - lambda v, m: f"cvat-sdk~={v.major}.{v.minor}.{v.patch}", + re.compile(r"^cvat-sdk==[\d.]+$", re.M), + lambda v, m: f"cvat-sdk=={v.major}.{v.minor}.{v.patch}", ), ] diff --git a/site/content/en/docs/api_sdk/cli/_index.md b/site/content/en/docs/api_sdk/cli/_index.md index ffa5be80676b..82bfad795fb0 100644 --- a/site/content/en/docs/api_sdk/cli/_index.md +++ b/site/content/en/docs/api_sdk/cli/_index.md @@ -29,6 +29,11 @@ The following subcommands are supported: - `backup` - back up a task - `auto-annotate` - automatically annotate a task using a local function +- Functions (Enterprise/Cloud only): + - `create-native` - create a function that can be powered by an agent + - `delete` - delete a function + - `run-agent` - process requests for a native function + ## Installation To install an [official release of CVAT CLI](https://pypi.org/project/cvat-cli/), use this command: @@ -316,3 +321,35 @@ see that command's examples for more information. ```bash cvat-cli project ls --json > list_of_projects.json ``` + +## Examples - functions + +**Note**: The functionality described in this section can only be used +with the CVAT Enterprise or CVAT Cloud. + +### Create + +- Create a function that uses a detection model from torchvision + and run an agent for it: + + ``` + cvat-cli function create-native "Faster R-CNN" \ + --function-module cvat_sdk.auto_annotation.functions.torchvision_detection \ + -p model_name=str:fasterrcnn_resnet50_fpn_v2 + cvat-cli function run-agent \ + --function-module cvat_sdk.auto_annotation.functions.torchvision_detection \ + -p model_name=str:fasterrcnn_resnet50_fpn_v2 + ``` + +These commands accept functions that implement the +{{< ilink "/docs/api_sdk/sdk/auto-annotation" "auto-annotation function interface" >}} +from the SDK, same as the `task auto-annotate` command. +See that command's examples for information on how to implement these functions +and specify them in the command line. + +### Delete + +- Delete functions with IDs 100 and 101: + ``` + cvat-cli function delete 100 101 + ``` From 185a00bcaa0243de59dacc682a8c5344826d5eec Mon Sep 17 00:00:00 2001 From: Dmitrii Lavrukhin Date: Thu, 26 Dec 2024 10:55:58 +0400 Subject: [PATCH 13/31] renaming yolov8 to ultralytics yolo (#8863) ### Motivation and context depends on https://github.com/cvat-ai/datumaro/pull/68 ### How has this been tested? ### Checklist - [ ] I submit my changes into the `develop` branch - [ ] I have created a changelog fragment - [ ] I have updated the documentation accordingly - [ ] I have added tests to cover my changes - [ ] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) - [ ] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/cvat-ai/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/cvat-ai/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/cvat-ai/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/cvat-ai/cvat/tree/develop/cvat-ui#versioning)) ### License - [ ] I submit _my code changes_ under the same [MIT License]( https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. ## Summary by CodeRabbit ## Release Notes - **New Features** - Updated documentation to reflect support for Ultralytics YOLO formats, including Detection, Segmentation, Pose, Oriented Bounding Boxes, and Classification. - Added new format entries in the documentation for Ultralytics YOLO. - **Bug Fixes** - Corrected naming conventions in tests and annotations from YOLOv8 to Ultralytics YOLO. - **Documentation** - Enhanced clarity and consistency in documentation regarding Ultralytics YOLO formats. - Reformatted tables and sections to improve readability. - **Chores** - Updated dependencies to the latest version of the `datumaro` package. - Adjusted Docker configurations to map local directories for improved access. --- README.md | 10 ++-- ...45339_dmitrii.lavrukhin_yolov8_renaming.md | 4 ++ cvat/apps/dataset_manager/formats/yolo.py | 60 +++++++++---------- .../tests/assets/annotations.json | 10 ++-- .../dataset_manager/tests/assets/tasks.json | 4 +- .../dataset_manager/tests/test_formats.py | 30 +++++----- .../tests/test_rest_api_formats.py | 24 ++++---- cvat/apps/engine/tests/test_rest_api.py | 11 ++-- cvat/requirements/base.in | 2 +- cvat/requirements/base.txt | 22 ++++--- .../en/docs/manual/advanced/formats/_index.md | 54 ++++++++--------- ...format-yolo-ultralytics-classification.md} | 10 ++-- ...t-yolov8.md => format-yolo-ultralytics.md} | 30 +++++----- ..._import_annotations_frames_dots_in_name.js | 4 +- tests/python/rest_api/test_projects.py | 10 ++-- tests/python/rest_api/test_tasks.py | 10 ++-- 16 files changed, 153 insertions(+), 142 deletions(-) create mode 100644 changelog.d/20241224_145339_dmitrii.lavrukhin_yolov8_renaming.md rename site/content/en/docs/manual/advanced/formats/{format-yolov8-classification.md => format-yolo-ultralytics-classification.md} (78%) rename site/content/en/docs/manual/advanced/formats/{format-yolov8.md => format-yolo-ultralytics.md} (87%) diff --git a/README.md b/README.md index 2a252b4eaed8..6ca7185523d5 100644 --- a/README.md +++ b/README.md @@ -175,11 +175,11 @@ For more information about the supported formats, see: | [Kitti Raw Format](https://www.cvlibs.net/datasets/kitti/raw_data.php) | ✔️ | ✔️ | | [LFW](http://vis-www.cs.umass.edu/lfw/) | ✔️ | ✔️ | | [Supervisely Point Cloud Format](https://docs.supervise.ly/data-organization/00_ann_format_navi) | ✔️ | ✔️ | -| [YOLOv8 Detection](https://docs.ultralytics.com/datasets/detect/) | ✔️ | ✔️ | -| [YOLOv8 Oriented Bounding Boxes](https://docs.ultralytics.com/datasets/obb/) | ✔️ | ✔️ | -| [YOLOv8 Segmentation](https://docs.ultralytics.com/datasets/segment/) | ✔️ | ✔️ | -| [YOLOv8 Pose](https://docs.ultralytics.com/datasets/pose/) | ✔️ | ✔️ | -| [YOLOv8 Classification](https://docs.ultralytics.com/datasets/classify/) | ✔️ | ✔️ | +| [Ultralytics YOLO Detection](https://docs.ultralytics.com/datasets/detect/) | ✔️ | ✔️ | +| [Ultralytics YOLO Oriented Bounding Boxes](https://docs.ultralytics.com/datasets/obb/) | ✔️ | ✔️ | +| [Ultralytics YOLO Segmentation](https://docs.ultralytics.com/datasets/segment/) | ✔️ | ✔️ | +| [Ultralytics YOLO Pose](https://docs.ultralytics.com/datasets/pose/) | ✔️ | ✔️ | +| [Ultralytics YOLO Classification](https://docs.ultralytics.com/datasets/classify/) | ✔️ | ✔️ | diff --git a/changelog.d/20241224_145339_dmitrii.lavrukhin_yolov8_renaming.md b/changelog.d/20241224_145339_dmitrii.lavrukhin_yolov8_renaming.md new file mode 100644 index 000000000000..957ff9666951 --- /dev/null +++ b/changelog.d/20241224_145339_dmitrii.lavrukhin_yolov8_renaming.md @@ -0,0 +1,4 @@ +### Changed + +- YOLOv8 formats renamed to Ultralytics YOLO formats + () diff --git a/cvat/apps/dataset_manager/formats/yolo.py b/cvat/apps/dataset_manager/formats/yolo.py index 887232b8e666..1a138557c862 100644 --- a/cvat/apps/dataset_manager/formats/yolo.py +++ b/cvat/apps/dataset_manager/formats/yolo.py @@ -77,53 +77,53 @@ def _import_yolo(*args, **kwargs): _import_common(*args, format_name="yolo", **kwargs) -@exporter(name='YOLOv8 Detection', ext='ZIP', version='1.0') -def _export_yolov8_detection(*args, **kwargs): - _export_common(*args, format_name='yolov8_detection', **kwargs) +@exporter(name='Ultralytics YOLO Detection', ext='ZIP', version='1.0') +def _export_yolo_ultralytics_detection(*args, **kwargs): + _export_common(*args, format_name='yolo_ultralytics_detection', **kwargs) -@exporter(name='YOLOv8 Oriented Bounding Boxes', ext='ZIP', version='1.0') -def _export_yolov8_oriented_boxes(*args, **kwargs): - _export_common(*args, format_name='yolov8_oriented_boxes', **kwargs) +@exporter(name='Ultralytics YOLO Oriented Bounding Boxes', ext='ZIP', version='1.0') +def _export_yolo_ultralytics_oriented_boxes(*args, **kwargs): + _export_common(*args, format_name='yolo_ultralytics_oriented_boxes', **kwargs) -@exporter(name='YOLOv8 Segmentation', ext='ZIP', version='1.0') -def _export_yolov8_segmentation(dst_file, temp_dir, instance_data, *, save_images=False): +@exporter(name='Ultralytics YOLO Segmentation', ext='ZIP', version='1.0') +def _export_yolo_ultralytics_segmentation(dst_file, temp_dir, instance_data, *, save_images=False): with GetCVATDataExtractor(instance_data, include_images=save_images) as extractor: dataset = Dataset.from_extractors(extractor, env=dm_env) dataset = dataset.transform('masks_to_polygons') - dataset.export(temp_dir, 'yolov8_segmentation', save_images=save_images) + dataset.export(temp_dir, 'yolo_ultralytics_segmentation', save_images=save_images) make_zip_archive(temp_dir, dst_file) -@exporter(name='YOLOv8 Pose', ext='ZIP', version='1.0') -def _export_yolov8_pose(*args, **kwargs): - _export_common(*args, format_name='yolov8_pose', **kwargs) +@exporter(name='Ultralytics YOLO Pose', ext='ZIP', version='1.0') +def _export_yolo_ultralytics_pose(*args, **kwargs): + _export_common(*args, format_name='yolo_ultralytics_pose', **kwargs) -@exporter(name='YOLOv8 Classification', ext='ZIP', version='1.0') -def _export_yolov8_classification(*args, **kwargs): - _export_common(*args, format_name='yolov8_classification', **kwargs) +@exporter(name='Ultralytics YOLO Classification', ext='ZIP', version='1.0') +def _export_yolo_ultralytics_classification(*args, **kwargs): + _export_common(*args, format_name='yolo_ultralytics_classification', **kwargs) -@importer(name='YOLOv8 Detection', ext="ZIP", version="1.0") -def _import_yolov8_detection(*args, **kwargs): - _import_common(*args, format_name="yolov8_detection", **kwargs) +@importer(name='Ultralytics YOLO Detection', ext="ZIP", version="1.0") +def _import_yolo_ultralytics_detection(*args, **kwargs): + _import_common(*args, format_name="yolo_ultralytics_detection", **kwargs) -@importer(name='YOLOv8 Segmentation', ext="ZIP", version="1.0") -def _import_yolov8_segmentation(*args, **kwargs): - _import_common(*args, format_name="yolov8_segmentation", **kwargs) +@importer(name='Ultralytics YOLO Segmentation', ext="ZIP", version="1.0") +def _import_yolo_ultralytics_segmentation(*args, **kwargs): + _import_common(*args, format_name="yolo_ultralytics_segmentation", **kwargs) -@importer(name='YOLOv8 Oriented Bounding Boxes', ext="ZIP", version="1.0") -def _import_yolov8_oriented_boxes(*args, **kwargs): - _import_common(*args, format_name="yolov8_oriented_boxes", **kwargs) +@importer(name='Ultralytics YOLO Oriented Bounding Boxes', ext="ZIP", version="1.0") +def _import_yolo_ultralytics_oriented_boxes(*args, **kwargs): + _import_common(*args, format_name="yolo_ultralytics_oriented_boxes", **kwargs) -@importer(name='YOLOv8 Pose', ext="ZIP", version="1.0") -def _import_yolov8_pose(src_file, temp_dir, instance_data, **kwargs): +@importer(name='Ultralytics YOLO Pose', ext="ZIP", version="1.0") +def _import_yolo_ultralytics_pose(src_file, temp_dir, instance_data, **kwargs): with GetCVATDataExtractor(instance_data) as extractor: point_categories = extractor.categories().get(AnnotationType.points) label_categories = extractor.categories().get(AnnotationType.label) @@ -135,12 +135,12 @@ def _import_yolov8_pose(src_file, temp_dir, instance_data, **kwargs): src_file, temp_dir, instance_data, - format_name="yolov8_pose", + format_name="yolo_ultralytics_pose", import_kwargs=dict(skeleton_sub_labels=true_skeleton_point_labels), **kwargs ) -@importer(name='YOLOv8 Classification', ext="ZIP", version="1.0") -def _import_yolov8_classification(*args, **kwargs): - _import_common(*args, format_name="yolov8_classification", **kwargs) +@importer(name='Ultralytics YOLO Classification', ext="ZIP", version="1.0") +def _import_yolo_ultralytics_classification(*args, **kwargs): + _import_common(*args, format_name="yolo_ultralytics_classification", **kwargs) diff --git a/cvat/apps/dataset_manager/tests/assets/annotations.json b/cvat/apps/dataset_manager/tests/assets/annotations.json index 9f7c27b94bcb..a0c9e8ff96d5 100644 --- a/cvat/apps/dataset_manager/tests/assets/annotations.json +++ b/cvat/apps/dataset_manager/tests/assets/annotations.json @@ -976,7 +976,7 @@ ], "tracks": [] }, - "YOLOv8 Classification 1.0": { + "Ultralytics YOLO Classification 1.0": { "version": 0, "tags": [ { @@ -990,7 +990,7 @@ "shapes": [], "tracks": [] }, - "YOLOv8 Detection 1.0": { + "Ultralytics YOLO Detection 1.0": { "version": 0, "tags": [], "shapes": [ @@ -1008,7 +1008,7 @@ ], "tracks": [] }, - "YOLOv8 Oriented Bounding Boxes 1.0": { + "Ultralytics YOLO Oriented Bounding Boxes 1.0": { "version": 0, "tags": [], "shapes": [ @@ -1027,7 +1027,7 @@ ], "tracks": [] }, - "YOLOv8 Segmentation 1.0": { + "Ultralytics YOLO Segmentation 1.0": { "version": 0, "tags": [], "shapes": [ @@ -1045,7 +1045,7 @@ ], "tracks": [] }, - "YOLOv8 Pose 1.0": { + "Ultralytics YOLO Pose 1.0": { "version": 0, "tags": [], "shapes": [ diff --git a/cvat/apps/dataset_manager/tests/assets/tasks.json b/cvat/apps/dataset_manager/tests/assets/tasks.json index 2c29ce712929..ad68c6f5aa5f 100644 --- a/cvat/apps/dataset_manager/tests/assets/tasks.json +++ b/cvat/apps/dataset_manager/tests/assets/tasks.json @@ -634,8 +634,8 @@ } ] }, - "YOLOv8 Pose 1.0": { - "name": "YOLOv8 pose task", + "Ultralytics YOLO Pose 1.0": { + "name": "Ultralytics YOLO pose task", "overlap": 0, "segment_size": 100, "labels": [ diff --git a/cvat/apps/dataset_manager/tests/test_formats.py b/cvat/apps/dataset_manager/tests/test_formats.py index 91a3081ca089..e6ba111f29f9 100644 --- a/cvat/apps/dataset_manager/tests/test_formats.py +++ b/cvat/apps/dataset_manager/tests/test_formats.py @@ -292,11 +292,11 @@ def test_export_formats_query(self): 'LFW 1.0', 'Cityscapes 1.0', 'Open Images V6 1.0', - 'YOLOv8 Classification 1.0', - 'YOLOv8 Oriented Bounding Boxes 1.0', - 'YOLOv8 Detection 1.0', - 'YOLOv8 Pose 1.0', - 'YOLOv8 Segmentation 1.0', + 'Ultralytics YOLO Classification 1.0', + 'Ultralytics YOLO Oriented Bounding Boxes 1.0', + 'Ultralytics YOLO Detection 1.0', + 'Ultralytics YOLO Pose 1.0', + 'Ultralytics YOLO Segmentation 1.0', }) def test_import_formats_query(self): @@ -329,11 +329,11 @@ def test_import_formats_query(self): 'Open Images V6 1.0', 'Datumaro 1.0', 'Datumaro 3D 1.0', - 'YOLOv8 Classification 1.0', - 'YOLOv8 Oriented Bounding Boxes 1.0', - 'YOLOv8 Detection 1.0', - 'YOLOv8 Pose 1.0', - 'YOLOv8 Segmentation 1.0', + 'Ultralytics YOLO Classification 1.0', + 'Ultralytics YOLO Oriented Bounding Boxes 1.0', + 'Ultralytics YOLO Detection 1.0', + 'Ultralytics YOLO Pose 1.0', + 'Ultralytics YOLO Segmentation 1.0', }) def test_exports(self): @@ -383,11 +383,11 @@ def test_empty_images_are_exported(self): # ('KITTI 1.0', 'kitti') format does not support empty annotations ('LFW 1.0', 'lfw'), # ('Cityscapes 1.0', 'cityscapes'), does not support, empty annotations - ('YOLOv8 Classification 1.0', 'yolov8_classification'), - ('YOLOv8 Oriented Bounding Boxes 1.0', 'yolov8_oriented_boxes'), - ('YOLOv8 Detection 1.0', 'yolov8_detection'), - ('YOLOv8 Pose 1.0', 'yolov8_pose'), - ('YOLOv8 Segmentation 1.0', 'yolov8_segmentation'), + ('Ultralytics YOLO Classification 1.0', 'yolo_ultralytics_classification'), + ('Ultralytics YOLO Oriented Bounding Boxes 1.0', 'yolo_ultralytics_oriented_boxes'), + ('Ultralytics YOLO Detection 1.0', 'yolo_ultralytics_detection'), + ('Ultralytics YOLO Pose 1.0', 'yolo_ultralytics_pose'), + ('Ultralytics YOLO Segmentation 1.0', 'yolo_ultralytics_segmentation'), ]: with self.subTest(format=format_name): if not dm.formats.registry.EXPORT_FORMATS[format_name].ENABLED: diff --git a/cvat/apps/dataset_manager/tests/test_rest_api_formats.py b/cvat/apps/dataset_manager/tests/test_rest_api_formats.py index 50883826b5a5..f3640b835bcb 100644 --- a/cvat/apps/dataset_manager/tests/test_rest_api_formats.py +++ b/cvat/apps/dataset_manager/tests/test_rest_api_formats.py @@ -55,12 +55,12 @@ DEFAULT_ATTRIBUTES_FORMATS = [ "VGGFace2 1.0", "WiderFace 1.0", - "YOLOv8 Classification 1.0", + "Ultralytics YOLO Classification 1.0", "YOLO 1.1", - "YOLOv8 Detection 1.0", - "YOLOv8 Segmentation 1.0", - "YOLOv8 Oriented Bounding Boxes 1.0", - "YOLOv8 Pose 1.0", + "Ultralytics YOLO Detection 1.0", + "Ultralytics YOLO Segmentation 1.0", + "Ultralytics YOLO Oriented Bounding Boxes 1.0", + "Ultralytics YOLO Pose 1.0", "PASCAL VOC 1.1", "Segmentation mask 1.1", "ImageNet 1.0", @@ -411,7 +411,7 @@ def test_api_v2_dump_and_upload_annotations_with_objects_type_is_shape(self): "Cityscapes 1.0", "COCO Keypoints 1.0", "ICDAR Localization 1.0", "ICDAR Recognition 1.0", "ICDAR Segmentation 1.0", "Market-1501 1.0", "MOT 1.1", - "YOLOv8 Pose 1.0", + "Ultralytics YOLO Pose 1.0", ]: task = self._create_task(tasks[dump_format_name], images) else: @@ -469,7 +469,7 @@ def test_api_v2_dump_and_upload_annotations_with_objects_type_is_shape(self): "Cityscapes 1.0", "COCO Keypoints 1.0", "ICDAR Localization 1.0", "ICDAR Recognition 1.0", "ICDAR Segmentation 1.0", "Market-1501 1.0", "MOT 1.1", - "YOLOv8 Pose 1.0", + "Ultralytics YOLO Pose 1.0", ]: task = self._create_task(tasks[upload_format_name], images) else: @@ -513,7 +513,7 @@ def test_api_v2_dump_annotations_with_objects_type_is_track(self): "Cityscapes 1.0", "COCO Keypoints 1.0", "ICDAR Localization 1.0", "ICDAR Recognition 1.0", "ICDAR Segmentation 1.0", "Market-1501 1.0", "MOT 1.1", - "YOLOv8 Pose 1.0", + "Ultralytics YOLO Pose 1.0", ]: task = self._create_task(tasks[dump_format_name], video) else: @@ -569,7 +569,7 @@ def test_api_v2_dump_annotations_with_objects_type_is_track(self): "Cityscapes 1.0", "COCO Keypoints 1.0", "ICDAR Localization 1.0", "ICDAR Recognition 1.0", "ICDAR Segmentation 1.0", "Market-1501 1.0", "MOT 1.1", - "YOLOv8 Pose 1.0", + "Ultralytics YOLO Pose 1.0", ]: task = self._create_task(tasks[upload_format_name], video) else: @@ -846,7 +846,7 @@ def test_api_v2_export_dataset(self): "Cityscapes 1.0", "COCO Keypoints 1.0", "ICDAR Localization 1.0", "ICDAR Recognition 1.0", "ICDAR Segmentation 1.0", "Market-1501 1.0", "MOT 1.1", - "YOLOv8 Pose 1.0", + "Ultralytics YOLO Pose 1.0", ]: task = self._create_task(tasks[dump_format_name], images) else: @@ -947,7 +947,7 @@ def test_api_v2_rewriting_annotations(self): if dump_format_name in [ "Market-1501 1.0", "ICDAR Localization 1.0", "ICDAR Recognition 1.0", - "ICDAR Segmentation 1.0", "COCO Keypoints 1.0", "YOLOv8 Pose 1.0", + "ICDAR Segmentation 1.0", "COCO Keypoints 1.0", "Ultralytics YOLO Pose 1.0", ]: task = self._create_task(tasks[dump_format_name], images) else: @@ -1058,7 +1058,7 @@ def test_api_v2_tasks_annotations_dump_and_upload_with_datumaro(self): "Market-1501 1.0", "Cityscapes 1.0", "ICDAR Localization 1.0", "ICDAR Recognition 1.0", "ICDAR Segmentation 1.0", "COCO Keypoints 1.0", - "YOLOv8 Pose 1.0", + "Ultralytics YOLO Pose 1.0", ]: task = self._create_task(tasks[dump_format_name], images) else: diff --git a/cvat/apps/engine/tests/test_rest_api.py b/cvat/apps/engine/tests/test_rest_api.py index e6ed6b6c0303..b0c5500eda4c 100644 --- a/cvat/apps/engine/tests/test_rest_api.py +++ b/cvat/apps/engine/tests/test_rest_api.py @@ -6127,13 +6127,13 @@ def _get_initial_annotation(annotation_format): elif annotation_format == "YOLO 1.1": annotations["shapes"] = rectangle_shapes_wo_attrs - elif annotation_format == "YOLOv8 Detection 1.0": + elif annotation_format == "Ultralytics YOLO Detection 1.0": annotations["shapes"] = rectangle_shapes_wo_attrs - elif annotation_format == "YOLOv8 Oriented Bounding Boxes 1.0": + elif annotation_format == "Ultralytics YOLO Oriented Bounding Boxes 1.0": annotations["shapes"] = rectangle_shapes_wo_attrs - elif annotation_format == "YOLOv8 Segmentation 1.0": + elif annotation_format == "Ultralytics YOLO Segmentation 1.0": annotations["shapes"] = polygon_shapes_wo_attrs elif annotation_format == "COCO 1.0": @@ -6493,7 +6493,10 @@ def etree_to_dict(t): self.assertEqual(meta["task"]["name"], task["name"]) elif format_name == "PASCAL VOC 1.1": self.assertTrue(zipfile.is_zipfile(content)) - elif format_name in ["YOLO 1.1", "YOLOv8 Detection 1.0", "YOLOv8 Segmentation 1.0", "YOLOv8 Oriented Bounding Boxes 1.0", "YOLOv8 Pose 1.0"]: + elif format_name in [ + "YOLO 1.1", "Ultralytics YOLO Detection 1.0", "Ultralytics YOLO Segmentation 1.0", + "Ultralytics YOLO Oriented Bounding Boxes 1.0", "Ultralytics YOLO Pose 1.0", + ]: self.assertTrue(zipfile.is_zipfile(content)) elif format_name in ['Kitti Raw Format 1.0','Sly Point Cloud Format 1.0']: self.assertTrue(zipfile.is_zipfile(content)) diff --git a/cvat/requirements/base.in b/cvat/requirements/base.in index fd86b51f99dc..b3900f010dda 100644 --- a/cvat/requirements/base.in +++ b/cvat/requirements/base.in @@ -12,7 +12,7 @@ azure-storage-blob==12.13.0 boto3==1.17.61 clickhouse-connect==0.6.8 coreapi==2.3.3 -datumaro @ git+https://github.com/cvat-ai/datumaro.git@bf0374689df50599a34a4f220b9e5329aca695ce +datumaro @ git+https://github.com/cvat-ai/datumaro.git@232c175ef1f3b7e55bd5162353df9c86a8116fde dj-pagination==2.5.0 # Despite direct indication allauth in requirements we should keep 'with_social' for dj-rest-auth # to avoid possible further versions conflicts (we use registration functionality) diff --git a/cvat/requirements/base.txt b/cvat/requirements/base.txt index fe4518b64e44..fce784b9481c 100644 --- a/cvat/requirements/base.txt +++ b/cvat/requirements/base.txt @@ -1,4 +1,4 @@ -# SHA1:1bed6e1afea11473b164df79d7d166f419074359 +# SHA1:5a3efd0a5c1892698d4394f019ef659275b10fdb # # This file is autogenerated by pip-compile-multi # To update, run: @@ -36,9 +36,9 @@ certifi==2024.12.14 # requests cffi==1.17.1 # via cryptography -charset-normalizer==3.4.0 +charset-normalizer==3.4.1 # via requests -click==8.1.7 +click==8.1.8 # via rq clickhouse-connect==0.6.8 # via -r cvat/requirements/base.in @@ -56,7 +56,7 @@ cryptography==44.0.0 # pyjwt cycler==0.12.1 # via matplotlib -datumaro @ git+https://github.com/cvat-ai/datumaro.git@bf0374689df50599a34a4f220b9e5329aca695ce +datumaro @ git+https://github.com/cvat-ai/datumaro.git@232c175ef1f3b7e55bd5162353df9c86a8116fde # via -r cvat/requirements/base.in defusedxml==0.7.1 # via @@ -148,7 +148,9 @@ idna==3.10 importlib-metadata==8.5.0 # via clickhouse-connect importlib-resources==6.4.5 - # via nibabel + # via + # matplotlib + # nibabel inflection==0.5.1 # via drf-spectacular isodate==0.7.2 @@ -157,7 +159,7 @@ isodate==0.7.2 # python3-saml itypes==1.2.0 # via coreapi -jinja2==3.1.4 +jinja2==3.1.5 # via coreschema jmespath==0.10.0 # via @@ -187,7 +189,7 @@ mmh3==5.0.1 # via pottery msrest==0.7.1 # via azure-storage-blob -networkx==3.4.2 +networkx==3.2.1 # via datumaro nibabel==5.3.2 # via datumaro @@ -213,7 +215,7 @@ pottery==3.0.0 # via -r cvat/requirements/base.in proto-plus==1.25.0 # via google-api-core -protobuf==5.29.1 +protobuf==5.29.2 # via # google-api-core # googleapis-common-protos @@ -354,6 +356,8 @@ xmlsec==1.3.14 # -r cvat/requirements/base.in # python3-saml zipp==3.21.0 - # via importlib-metadata + # via + # importlib-metadata + # importlib-resources zstandard==0.23.0 # via clickhouse-connect diff --git a/site/content/en/docs/manual/advanced/formats/_index.md b/site/content/en/docs/manual/advanced/formats/_index.md index f4d30a45baa1..e8818e3742f6 100644 --- a/site/content/en/docs/manual/advanced/formats/_index.md +++ b/site/content/en/docs/manual/advanced/formats/_index.md @@ -23,34 +23,34 @@ The table below outlines the available formats for data export in CVAT. -| Format | Type | Computer Vision Task | Models | Shapes | Attributes | Video Tracks | -|------------------------------------------------------------------------------------------------------------------------------------|---------------|-------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------| -------------------- | ------------- | -| [CamVid 1.0](format-camvid) | .txt
.png | Semantic
Segmentation | U-Net, SegNet, DeepLab,
PSPNet, FCN, Mask R-CNN,
ICNet, ERFNet, HRNet,
V-Net, and others. | Polygons | Not supported | Not supported | -| [Cityscapes 1.0](format-cityscapes) | .txt
.png | Semantic
Segmentation | U-Net, SegNet, DeepLab,
PSPNet, FCN, ERFNet,
ICNet, Mask R-CNN, HRNet,
ENet, and others. | Polygons | Specific attributes | Not supported | -| [COCO 1.0](format-coco) | JSON | Detection, Semantic
Segmentation | YOLO (You Only Look Once),
Faster R-CNN, Mask R-CNN, SSD (Single Shot MultiBox Detector),
RetinaNet, EfficientDet, UNet,
DeepLabv3+, CenterNet, Cascade R-CNN, and others. | Bounding Boxes, Polygons | Specific attributes | Not supported | -| [COCO Keypoints 1.0](coco-keypoints) | .xml | Keypoints | OpenPose, PoseNet, AlphaPose,
SPM (Single Person Model),
Mask R-CNN with Keypoint Detection:, and others. | Skeletons | Specific attributes | Not supported | -| {{< ilink "/docs/manual/advanced/formats/format-cvat#cvat-for-image-export" "CVAT for images 1.1" >}} | .xml | Any in 2D except for Video Tracking | Any model that can decode the format. | Bounding Boxes, Polygons,
Polylines, Points, Cuboids,
Skeletons, Ellipses, Masks, Tags. | All attributes | Not supported | -| {{< ilink "/docs/manual/advanced/formats/format-cvat#cvat-for-videos-export" "CVAT for video 1.1" >}} | .xml | Any in 2D except for Classification | Any model that can decode the format. | Bounding Boxes, Polygons,
Polylines, Points, Cuboids,
Skeletons, Ellipses, Masks. | All attributes | Supported | -| [Datumaro 1.0](format-datumaro) | JSON | Any | Any model that can decode the format.
Main format in [Datumaro](https://github.com/openvinotoolkit/datumaro) framework | Bounding Boxes, Polygons,
Polylines, Points, Cuboids,
Skeletons, Ellipses, Masks, Tags. | All attributes | Supported | +| Format | Type | Computer Vision Task | Models | Shapes | Attributes | Video Tracks | +|-----------------------------------------------------------------------------------------------------------------------------|---------------|-------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------| -------------------- | ------------- | +| [CamVid 1.0](format-camvid) | .txt
.png | Semantic
Segmentation | U-Net, SegNet, DeepLab,
PSPNet, FCN, Mask R-CNN,
ICNet, ERFNet, HRNet,
V-Net, and others. | Polygons | Not supported | Not supported | +| [Cityscapes 1.0](format-cityscapes) | .txt
.png | Semantic
Segmentation | U-Net, SegNet, DeepLab,
PSPNet, FCN, ERFNet,
ICNet, Mask R-CNN, HRNet,
ENet, and others. | Polygons | Specific attributes | Not supported | +| [COCO 1.0](format-coco) | JSON | Detection, Semantic
Segmentation | YOLO (You Only Look Once),
Faster R-CNN, Mask R-CNN, SSD (Single Shot MultiBox Detector),
RetinaNet, EfficientDet, UNet,
DeepLabv3+, CenterNet, Cascade R-CNN, and others. | Bounding Boxes, Polygons | Specific attributes | Not supported | +| [COCO Keypoints 1.0](coco-keypoints) | .xml | Keypoints | OpenPose, PoseNet, AlphaPose,
SPM (Single Person Model),
Mask R-CNN with Keypoint Detection:, and others. | Skeletons | Specific attributes | Not supported | +| {{< ilink "/docs/manual/advanced/formats/format-cvat#cvat-for-image-export" "CVAT for images 1.1" >}} | .xml | Any in 2D except for Video Tracking | Any model that can decode the format. | Bounding Boxes, Polygons,
Polylines, Points, Cuboids,
Skeletons, Ellipses, Masks, Tags. | All attributes | Not supported | +| {{< ilink "/docs/manual/advanced/formats/format-cvat#cvat-for-videos-export" "CVAT for video 1.1" >}} | .xml | Any in 2D except for Classification | Any model that can decode the format. | Bounding Boxes, Polygons,
Polylines, Points, Cuboids,
Skeletons, Ellipses, Masks. | All attributes | Supported | +| [Datumaro 1.0](format-datumaro) | JSON | Any | Any model that can decode the format.
Main format in [Datumaro](https://github.com/openvinotoolkit/datumaro) framework | Bounding Boxes, Polygons,
Polylines, Points, Cuboids,
Skeletons, Ellipses, Masks, Tags. | All attributes | Supported | | [ICDAR](format-icdar)
Includes ICDAR Recognition 1.0,
ICDAR Detection 1.0,
and ICDAR Segmentation 1.0
descriptions. | .txt | Text recognition,
Text detection,
Text segmentation | EAST: Efficient and Accurate
Scene Text Detector, CRNN, Mask TextSpotter, TextSnake,
and others. | Tag, Bounding Boxes, Polygons | Specific attributes | Not supported | -| [ImageNet 1.0](format-imagenet) | .jpg
.txt | Semantic Segmentation,
Classification,
Detection | VGG (VGG16, VGG19), Inception, YOLO, Faster R-CNN , U-Net, and others | Tags | No attributes | Not supported | -| [KITTI 1.0](format-kitti) | .txt
.png | Semantic Segmentation, Detection, 3D | PointPillars, SECOND, AVOD, YOLO, DeepSORT, PWC-Net, ORB-SLAM, and others. | Bounding Boxes, Polygons | Specific attributes | Not supported | -| [LabelMe 3.0](format-labelme) | .xml | Compatibility,
Semantic Segmentation | U-Net, Mask R-CNN, Fast R-CNN,
Faster R-CNN, DeepLab, YOLO,
and others. | Bounding Boxes, Polygons | Supported (Polygons) | Not supported | -| [LFW 1.0](format-lfw) | .txt | Verification,
Face recognition | OpenFace, VGGFace & VGGFace2,
FaceNet, ArcFace,
and others. | Tags, Skeletons | Specific attributes | Not supported | -| [Market-1501 1.0](format-market1501) | .txt | Re-identification | Triplet Loss Networks,
Deep ReID models, and others. | Bounding Boxes | Specific attributes | Not supported | -| [MOT 1.0](format-mot) | .txt | Video Tracking,
Detection | SORT, MOT-Net, IOU Tracker,
and others. | Bounding Boxes | Specific attributes | Supported | -| [MOTS PNG 1.0](format-mots) | .png
.txt | Video Tracking,
Detection | SORT, MOT-Net, IOU Tracker,
and others. | Bounding Boxes, Masks | Specific attributes | Supported | -| [Open Images 1.0](format-openimages) | .csv | Detection,
Classification,
Semantic Segmentation | Faster R-CNN, YOLO, U-Net,
CornerNet, and others. | Bounding Boxes, Tags, Polygons | Specific attributes | Not supported | -| [PASCAL VOC 1.0](format-voc) | .xml | Classification, Detection | Faster R-CNN, SSD, YOLO,
AlexNet, and others. | Bounding Boxes, Tags, Polygons | Specific attributes | Not supported | -| [Segmentation Mask 1.0](format-smask) | .txt | Semantic Segmentation | Faster R-CNN, SSD, YOLO,
AlexNet, and others. | Polygons | No attributes | Not supported | -| [VGGFace2 1.0](format-vggface2) | .csv | Face recognition | VGGFace, ResNet, Inception,
and others. | Bounding Boxes, Points | No attributes | Not supported | -| [WIDER Face 1.0](format-widerface) | .txt | Detection | SSD (Single Shot MultiBox Detector), Faster R-CNN, YOLO,
and others. | Bounding Boxes, Tags | Specific attributes | Not supported | -| [YOLO 1.0](format-yolo) | .txt | Detection | YOLOv1, YOLOv2 (YOLO9000),
YOLOv3, YOLOv4, and others. | Bounding Boxes | No attributes | Not supported | -| [YOLOv8 Detection 1.0](format-yolov8) | .txt | Detection | YOLOv8 | Bounding Boxes | No attributes | Not supported | -| [YOLOv8 Segmentation 1.0](format-yolov8) | .txt | Instance Segmentation | YOLOv8 | Polygons, Masks | No attributes | Not supported | -| [YOLOv8 Pose 1.0](format-yolov8) | .txt | Keypoints | YOLOv8 | Skeletons | No attributes | Not supported | -| [YOLOv8 Oriented Bounding Boxes 1.0](format-yolov8) | .txt | Detection | YOLOv8 | Bounding Boxes | No attributes | Not supported | -| [YOLOv8 Classification 1.0](format-yolov8-classification) | .jpg | Classification | YOLOv8 | Tags | No attributes | Not supported | +| [ImageNet 1.0](format-imagenet) | .jpg
.txt | Semantic Segmentation,
Classification,
Detection | VGG (VGG16, VGG19), Inception, YOLO, Faster R-CNN , U-Net, and others | Tags | No attributes | Not supported | +| [KITTI 1.0](format-kitti) | .txt
.png | Semantic Segmentation, Detection, 3D | PointPillars, SECOND, AVOD, YOLO, DeepSORT, PWC-Net, ORB-SLAM, and others. | Bounding Boxes, Polygons | Specific attributes | Not supported | +| [LabelMe 3.0](format-labelme) | .xml | Compatibility,
Semantic Segmentation | U-Net, Mask R-CNN, Fast R-CNN,
Faster R-CNN, DeepLab, YOLO,
and others. | Bounding Boxes, Polygons | Supported (Polygons) | Not supported | +| [LFW 1.0](format-lfw) | .txt | Verification,
Face recognition | OpenFace, VGGFace & VGGFace2,
FaceNet, ArcFace,
and others. | Tags, Skeletons | Specific attributes | Not supported | +| [Market-1501 1.0](format-market1501) | .txt | Re-identification | Triplet Loss Networks,
Deep ReID models, and others. | Bounding Boxes | Specific attributes | Not supported | +| [MOT 1.0](format-mot) | .txt | Video Tracking,
Detection | SORT, MOT-Net, IOU Tracker,
and others. | Bounding Boxes | Specific attributes | Supported | +| [MOTS PNG 1.0](format-mots) | .png
.txt | Video Tracking,
Detection | SORT, MOT-Net, IOU Tracker,
and others. | Bounding Boxes, Masks | Specific attributes | Supported | +| [Open Images 1.0](format-openimages) | .csv | Detection,
Classification,
Semantic Segmentation | Faster R-CNN, YOLO, U-Net,
CornerNet, and others. | Bounding Boxes, Tags, Polygons | Specific attributes | Not supported | +| [PASCAL VOC 1.0](format-voc) | .xml | Classification, Detection | Faster R-CNN, SSD, YOLO,
AlexNet, and others. | Bounding Boxes, Tags, Polygons | Specific attributes | Not supported | +| [Segmentation Mask 1.0](format-smask) | .txt | Semantic Segmentation | Faster R-CNN, SSD, YOLO,
AlexNet, and others. | Polygons | No attributes | Not supported | +| [VGGFace2 1.0](format-vggface2) | .csv | Face recognition | VGGFace, ResNet, Inception,
and others. | Bounding Boxes, Points | No attributes | Not supported | +| [WIDER Face 1.0](format-widerface) | .txt | Detection | SSD (Single Shot MultiBox Detector), Faster R-CNN, YOLO,
and others. | Bounding Boxes, Tags | Specific attributes | Not supported | +| [YOLO 1.0](format-yolo) | .txt | Detection | YOLOv1, YOLOv2 (YOLO9000),
YOLOv3, YOLOv4, and others. | Bounding Boxes | No attributes | Not supported | +| [Ultralytics YOLO Detection 1.0](format-yolo-ultralytics) | .txt | Detection | YOLOv8 | Bounding Boxes | No attributes | Not supported | +| [Ultralytics YOLO Segmentation 1.0](format-yolo-ultralytics) | .txt | Instance Segmentation | YOLOv8 | Polygons, Masks | No attributes | Not supported | +| [Ultralytics YOLO Pose 1.0](format-yolo-ultralytics) | .txt | Keypoints | YOLOv8 | Skeletons | No attributes | Not supported | +| [Ultralytics YOLO Oriented Bounding Boxes 1.0](format-yolo-ultralytics) | .txt | Detection | YOLOv8 | Bounding Boxes | No attributes | Not supported | +| [Ultralytics YOLO Classification 1.0](format-yolo-ultralytics-classification) | .jpg | Classification | YOLOv8 | Tags | No attributes | Not supported | diff --git a/site/content/en/docs/manual/advanced/formats/format-yolov8-classification.md b/site/content/en/docs/manual/advanced/formats/format-yolo-ultralytics-classification.md similarity index 78% rename from site/content/en/docs/manual/advanced/formats/format-yolov8-classification.md rename to site/content/en/docs/manual/advanced/formats/format-yolo-ultralytics-classification.md index 8857c11518b3..734fd229a052 100644 --- a/site/content/en/docs/manual/advanced/formats/format-yolov8-classification.md +++ b/site/content/en/docs/manual/advanced/formats/format-yolo-ultralytics-classification.md @@ -1,16 +1,16 @@ --- -title: 'YOLOv8-Classification' -linkTitle: 'YOLOv8-Classification' +title: 'Ultralytics-YOLO-Classification' +linkTitle: 'Ultralytics-YOLO-Classification' weight: 7 -description: 'How to export and import data in YOLOv8 Classification format' +description: 'How to export and import data in Ultralytics YOLO Classification format' --- For more information, see: - [Format specification](https://docs.ultralytics.com/datasets/classify/) -- [Dataset examples](https://github.com/cvat-ai/datumaro/tree/develop/tests/assets/yolo_dataset/yolov8_classification) +- [Dataset examples](https://github.com/cvat-ai/datumaro/tree/develop/tests/assets/yolo_dataset/yolo_ultralytics_classification) -## YOLOv8 Classification export +## Ultralytics YOLO Classification export For export of images: diff --git a/site/content/en/docs/manual/advanced/formats/format-yolov8.md b/site/content/en/docs/manual/advanced/formats/format-yolo-ultralytics.md similarity index 87% rename from site/content/en/docs/manual/advanced/formats/format-yolov8.md rename to site/content/en/docs/manual/advanced/formats/format-yolo-ultralytics.md index 9fc4e0aba127..4d99912de014 100644 --- a/site/content/en/docs/manual/advanced/formats/format-yolov8.md +++ b/site/content/en/docs/manual/advanced/formats/format-yolo-ultralytics.md @@ -1,24 +1,24 @@ --- -title: 'YOLOv8' -linkTitle: 'YOLOv8' +title: 'Ultralytics YOLO' +linkTitle: 'Ultralytics YOLO' weight: 7 -description: 'How to export and import data in YOLOv8 formats' +description: 'How to export and import data in Ultralytics YOLO formats' --- -YOLOv8 is a format family which consists of four formats: +Ultralytics YOLO is a format family which consists of four formats: - [Detection](https://docs.ultralytics.com/datasets/detect/) - [Oriented bounding Box](https://docs.ultralytics.com/datasets/obb/) - [Segmentation](https://docs.ultralytics.com/datasets/segment/) - [Pose](https://docs.ultralytics.com/datasets/pose/) Dataset examples: -- [Detection](https://github.com/cvat-ai/datumaro/tree/develop/tests/assets/yolo_dataset/yolov8_detection) -- [Oriented Bounding Boxes](https://github.com/cvat-ai/datumaro/tree/develop/tests/assets/yolo_dataset/yolov8_oriented_boxes) -- [Segmentation](https://github.com/cvat-ai/datumaro/tree/develop/tests/assets/yolo_dataset/yolov8_segmentation) -- [Pose](https://github.com/cvat-ai/datumaro/tree/develop/tests/assets/yolo_dataset/yolov8_pose) +- [Detection](https://github.com/cvat-ai/datumaro/tree/develop/tests/assets/yolo_dataset/yolo_ultralytics_detection) +- [Oriented Bounding Boxes](https://github.com/cvat-ai/datumaro/tree/develop/tests/assets/yolo_dataset/yolo_ultralytics_oriented_boxes) +- [Segmentation](https://github.com/cvat-ai/datumaro/tree/develop/tests/assets/yolo_dataset/yolo_ultralytics_segmentation) +- [Pose](https://github.com/cvat-ai/datumaro/tree/develop/tests/assets/yolo_dataset/yolo_ultralytics_pose) -## YOLOv8 export +## Ultralytics YOLO export For export of images: @@ -59,7 +59,7 @@ images//image2.jpg path: ./ # dataset root dir train: train.txt # train images (relative to 'path') -# YOLOv8 Pose specific field +# Ultralytics YOLO Pose specific field # First number is the number of points in a skeleton. # If there are several skeletons with different number of points, it is the greatest number of points # Second number defines the format of point info in annotation txt files @@ -75,7 +75,7 @@ names: # .txt: # content depends on format -# YOLOv8 Detection: +# Ultralytics YOLO Detection: # label_id - id from names field of data.yaml # cx, cy - relative coordinates of the bbox center # rw, rh - relative size of the bbox @@ -83,19 +83,19 @@ names: 1 0.3 0.8 0.1 0.3 2 0.7 0.2 0.3 0.1 -# YOLOv8 Oriented Bounding Boxes: +# Ultralytics YOLO Oriented Bounding Boxes: # xn, yn - relative coordinates of the n-th point # label_id x1 y1 x2 y2 x3 y3 x4 y4 1 0.3 0.8 0.1 0.3 0.4 0.5 0.7 0.5 2 0.7 0.2 0.3 0.1 0.4 0.5 0.5 0.6 -# YOLOv8 Segmentation: +# Ultralytics YOLO Segmentation: # xn, yn - relative coordinates of the n-th point # label_id x1 y1 x2 y2 x3 y3 ... 1 0.3 0.8 0.1 0.3 0.4 0.5 2 0.7 0.2 0.3 0.1 0.4 0.5 0.5 0.6 0.7 0.5 -# YOLOv8 Pose: +# Ultralytics YOLO Pose: # cx, cy - relative coordinates of the bbox center # rw, rh - relative size of the bbox # xn, yn - relative coordinates of the n-th point @@ -131,7 +131,7 @@ For example, `frame_000001.txt` serves as the annotation for the Uploaded file: a zip archive of the same structure as above. -For compatibility with other tools exporting in Ultralytics YOLO format +For compatibility with other tools exporting in Ultralytics YOLO format (e.g. [roboflow](https://roboflow.com/formats/yolov8-pytorch-txt)), CVAT supports datasets with the inverted directory order of subset and "images" or "labels", i.e. both `train/images/`, `images/train/` are valid inputs. diff --git a/tests/cypress/e2e/actions_tasks/issue_2473_import_annotations_frames_dots_in_name.js b/tests/cypress/e2e/actions_tasks/issue_2473_import_annotations_frames_dots_in_name.js index 7398019d3903..3c02ba2eada7 100644 --- a/tests/cypress/e2e/actions_tasks/issue_2473_import_annotations_frames_dots_in_name.js +++ b/tests/cypress/e2e/actions_tasks/issue_2473_import_annotations_frames_dots_in_name.js @@ -33,7 +33,7 @@ context('Import annotations for frames with dots in name.', { browser: '!firefox secondY: 450, }; - const dumpType = 'YOLO'; + const dumpType = 'YOLO 1.1'; let annotationArchiveName = ''; function confirmUpdate(modalWindowClassName) { @@ -114,7 +114,7 @@ context('Import annotations for frames with dots in name.', { browser: '!firefox cy.interactMenu('Upload annotations'); cy.intercept('GET', '/api/jobs/**/annotations?**').as('uploadAnnotationsGet'); uploadAnnotation( - dumpType.split(' ')[0], + dumpType, annotationArchiveName, '.cvat-modal-content-load-job-annotation', ); diff --git a/tests/python/rest_api/test_projects.py b/tests/python/rest_api/test_projects.py index d3d807d68088..7785454c8839 100644 --- a/tests/python/rest_api/test_projects.py +++ b/tests/python/rest_api/test_projects.py @@ -714,7 +714,7 @@ def test_can_import_dataset_in_org(self, admin_user: str): ("CVAT for images 1.1", "CVAT 1.1"), ("CVAT for video 1.1", "CVAT 1.1"), ("Datumaro 1.0", "Datumaro 1.0"), - ("YOLOv8 Pose 1.0", "YOLOv8 Pose 1.0"), + ("Ultralytics YOLO Pose 1.0", "Ultralytics YOLO Pose 1.0"), ), ) def test_can_export_and_import_dataset_with_skeletons( @@ -1078,10 +1078,10 @@ def _export_task(task_id: int, format_name: str) -> io.BytesIO: ("LFW 1.0", "{subset}/images/"), ("Cityscapes 1.0", "imgsFine/leftImg8bit/{subset}/"), ("Open Images V6 1.0", "images/{subset}/"), - ("YOLOv8 Detection 1.0", "images/{subset}/"), - ("YOLOv8 Oriented Bounding Boxes 1.0", "images/{subset}/"), - ("YOLOv8 Segmentation 1.0", "images/{subset}/"), - ("YOLOv8 Pose 1.0", "images/{subset}/"), + ("Ultralytics YOLO Detection 1.0", "images/{subset}/"), + ("Ultralytics YOLO Oriented Bounding Boxes 1.0", "images/{subset}/"), + ("Ultralytics YOLO Segmentation 1.0", "images/{subset}/"), + ("Ultralytics YOLO Pose 1.0", "images/{subset}/"), ], ) @pytest.mark.parametrize("api_version", (1, 2)) diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index 15496cc31f73..70d8a84827bb 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -947,7 +947,7 @@ def test_export_dataset_after_deleting_related_cloud_storage( [ ("Datumaro 1.0", "", "images/{subset}"), ("YOLO 1.1", "train", "obj_{subset}_data"), - ("YOLOv8 Detection 1.0", "train", "images/{subset}"), + ("Ultralytics YOLO Detection 1.0", "train", "images/{subset}"), ], ) @pytest.mark.parametrize("api_version", (1, 2)) @@ -5422,10 +5422,10 @@ def test_can_import_datumaro_json(self, admin_user, tasks, dimension): "Open Images V6 1.0", "Datumaro 1.0", "Datumaro 3D 1.0", - "YOLOv8 Oriented Bounding Boxes 1.0", - "YOLOv8 Detection 1.0", - "YOLOv8 Pose 1.0", - "YOLOv8 Segmentation 1.0", + "Ultralytics YOLO Oriented Bounding Boxes 1.0", + "Ultralytics YOLO Detection 1.0", + "Ultralytics YOLO Pose 1.0", + "Ultralytics YOLO Segmentation 1.0", ], ) def test_check_import_error_on_wrong_file_structure(self, tasks_with_shapes, format_name): From 9a25291e676845f4863e5c5330d2e3c876dc001b Mon Sep 17 00:00:00 2001 From: Maria Khrustaleva Date: Fri, 27 Dec 2024 10:44:57 +0100 Subject: [PATCH 14/31] Fix link to the authentication with Amazon Cognito (#8877) --- .../social-accounts-configuration.md | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/site/content/en/docs/enterprise/social-accounts-configuration.md b/site/content/en/docs/enterprise/social-accounts-configuration.md index ee1dd7d4f322..f9489d0a8cfb 100644 --- a/site/content/en/docs/enterprise/social-accounts-configuration.md +++ b/site/content/en/docs/enterprise/social-accounts-configuration.md @@ -19,19 +19,13 @@ such benefits as: Currently, we offer three options: -- Authentication with Github. -- Authentication with Google. -- Authentication with Amazon Cognito. +- [Authentication with Google](#authentication-with-google) +- [Authentication with GitHub](#authentication-with-github) +- [Authentication with Amazon Cognito](#authentication-with-amazon-cognito) With more to come soon. Stay tuned! -See: - -- [Enable authentication with a Google account](#enable-authentication-with-a-google-account) -- [Enable authentication with a GitHub account](#enable-authentication-with-a-github-account) -- [Enable authentication with an Amazon Cognito](#enable-authentication-with-an-amazon-cognito) - -## Enable authentication with a Google account +## Authentication with Google To enable authentication, do the following: @@ -72,7 +66,7 @@ To enable authentication, do the following: docker compose -f docker-compose.yml -f docker-compose.dev.yml -f docker-compose.override.yml up -d --build ``` -## Enable authentication with a GitHub account +## Authentication with GitHub There are 2 basic steps to enable GitHub account authentication. @@ -106,7 +100,7 @@ There are 2 basic steps to enable GitHub account authentication. > but don't forget to add required permissions. >
In the **Permission** > **Account permissions** > **Email addresses** must be set to **read-only**. -## Enable authentication with Amazon Cognito +## Authentication with Amazon Cognito To enable authentication with Amazon Cognito for your CVAT instance, follow these steps: From 26f08ce73e2bae8b37a75bc5e079182fcfccb928 Mon Sep 17 00:00:00 2001 From: Kirill Lakhov Date: Sun, 29 Dec 2024 14:55:25 +0300 Subject: [PATCH 15/31] Fix an issue with deleting frames (#8872) ### Motivation and context Previously, in the Honeypots patch, we introduced periodic metadata updates to allow the UI to reflect chunk changes dynamically. However, this introduced a couple of issues: 1. The object received from the server was incorrectly utilized by the UI, preventing frame deletion after requesting updated metadata. 2. The new object overwritten unsaved deleted frame data, leading to the loss of user data. ### Steps to Reproduce the Frame Deletion Issue 1. Set `jobMetaDataReloadPeriod` in `config.ts` of `cvat-core` to a small interval, such as 15 seconds (the default is 1 hour). 2. Open a job and ensure the `/data/meta` request is sent. 3. Wait for the `jobMetaDataReloadPeriod` to elapse. 4. Change the frame and confirm that a new `/data/meta` request is sent. 5. Attempt to delete any frame and observe that it is not deleted. ![deleting-frame](https://github.com/user-attachments/assets/26357b3d-9c9e-4639-bd51-3fa8f771b055) ### Resolution To fix this, I removed the secondary storage of `jobMeta` in `frameDataCache`. Now, it uses an asynchronous getter that fetches data directly from `frameMetaCache`. Having two caches for the same data caused synchronization issues, which led to this bug. To fix second issue I added merge logic inside getFramesMeta function that will update new object with unsaved data. ### How has this been tested? ### Checklist - [x] I submit my changes into the `develop` branch - [ ] I have created a changelog fragment - ~~[ ] I have updated the documentation accordingly~~ - [ ] I have added tests to cover my changes - [ ] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) - [x] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/cvat-ai/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/cvat-ai/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/cvat-ai/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/cvat-ai/cvat/tree/develop/cvat-ui#versioning)) ### License - [x] I submit _my code changes_ under the same [MIT License]( https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. ## Summary by CodeRabbit - **New Features** - Introduced a new enum to standardize the representation of deleted frame states. - Enhanced metadata retrieval efficiency with updated caching logic. - **Bug Fixes** - Improved clarity and maintainability in handling the deletion status of frames. - **Refactor** - Simplified logic for marking frames as deleted or restored using the new enum. --- cvat-core/src/frames.ts | 124 ++++++++++++++++++------ cvat-core/src/session-implementation.ts | 2 +- 2 files changed, 93 insertions(+), 33 deletions(-) diff --git a/cvat-core/src/frames.ts b/cvat-core/src/frames.ts index 3305edfc5aab..b772aca4ca4a 100644 --- a/cvat-core/src/frames.ts +++ b/cvat-core/src/frames.ts @@ -16,7 +16,6 @@ import config from './config'; // frame storage by job id const frameDataCache: Record; getChunk: (chunkIndex: number, quality: ChunkQuality) => Promise; + getMeta: () => Promise; }> = {}; // frame meta data storage by job id const frameMetaCache: Record> = {}; +enum DeletedFrameState { + DELETED = 'deleted', + RESTORED = 'restored', +} + +interface FramesMetaDataUpdatedData { + deletedFrames: Record; +} + export class FramesMetaData { public chunkSize: number; public deletedFrames: Record; @@ -82,10 +91,13 @@ export class FramesMetaData { if (Object.prototype.hasOwnProperty.call(data, property) && property in initialData) { if (property === 'deleted_frames') { const update = (frame: string, remove: boolean): void => { - if (this.#updateTrigger.get(`deletedFrames:${frame}:${!remove}`)) { - this.#updateTrigger.resetField(`deletedFrames:${frame}:${!remove}`); + const [state, oppositeState] = remove ? + [DeletedFrameState.DELETED, DeletedFrameState.RESTORED] : + [DeletedFrameState.RESTORED, DeletedFrameState.DELETED]; + if (this.#updateTrigger.get(`deletedFrames:${frame}:${oppositeState}`)) { + this.#updateTrigger.resetField(`deletedFrames:${frame}:${oppositeState}`); } else { - this.#updateTrigger.update(`deletedFrames:${frame}:${remove}`); + this.#updateTrigger.update(`deletedFrames:${frame}:${state}`); } }; @@ -178,8 +190,17 @@ export class FramesMetaData { return (dataFrameNumber - this.startFrame) / this.frameStep; } - getUpdated(): Record { - return this.#updateTrigger.getUpdated(this); + getUpdated(): FramesMetaDataUpdatedData { + const updatedFields = this.#updateTrigger.getUpdated(this); + const deletedFrames: FramesMetaDataUpdatedData['deletedFrames'] = {}; + for (const key in updatedFields) { + if (Object.hasOwn(updatedFields, key) && key.startsWith('deletedFrames')) { + const [, frame, state] = key.split(':'); + deletedFrames[frame] = state; + } + } + + return { deletedFrames }; } resetUpdated(): void { @@ -340,17 +361,18 @@ class PrefetchAnalyzer { } Object.defineProperty(FrameData.prototype.data, 'implementation', { - value(this: FrameData, onServerRequest) { + async value(this: FrameData, onServerRequest) { + const { + provider, prefetchAnalyzer, chunkSize, jobStartFrame, + decodeForward, forwardStep, decodedBlocksCacheSize, + } = frameDataCache[this.jobID]; + const meta = await frameDataCache[this.jobID].getMeta(); + return new Promise<{ renderWidth: number; renderHeight: number; imageData: ImageBitmap | Blob; } | Blob>((resolve, reject) => { - const { - meta, provider, prefetchAnalyzer, chunkSize, jobStartFrame, - decodeForward, forwardStep, decodedBlocksCacheSize, - } = frameDataCache[this.jobID]; - const requestId = +_.uniqueId(); const requestedDataFrameNumber = meta.getDataFrameNumber(this.number - jobStartFrame); const chunkIndex = meta.getFrameChunkIndex(requestedDataFrameNumber); @@ -536,6 +558,34 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', { writable: false, }); +function mergeMetaData( + nextData: SerializedFramesMetaData, + previousData?: Promise, +): Promise { + const framesMetaData = new FramesMetaData({ + ...nextData, + deleted_frames: Object.fromEntries(nextData.deleted_frames.map((_frame) => [_frame, true])), + }); + + if (previousData instanceof Promise) { + return previousData.then((prevMeta) => { + const updatedFields = prevMeta.getUpdated(); + const updatedDeletedFrames = updatedFields.deletedFrames; + for (const [frame, state] of Object.entries(updatedDeletedFrames)) { + if (state === DeletedFrameState.DELETED) { + framesMetaData.deletedFrames[frame] = true; + } else if (state === DeletedFrameState.RESTORED) { + delete framesMetaData.deletedFrames[frame]; + } + } + + return framesMetaData; + }); + } + + return Promise.resolve(framesMetaData); +} + export function getFramesMeta(type: 'job' | 'task', id: number, forceReload = false): Promise { if (type === 'task') { // we do not cache task meta currently. So, each new call will results to the server request @@ -551,11 +601,11 @@ export function getFramesMeta(type: 'job' | 'task', id: number, forceReload = fa const previousCache = frameMetaCache[id]; frameMetaCache[id] = new Promise((resolve, reject) => { serverProxy.frames.getMeta('job', id).then((serialized) => { - const framesMetaData = new FramesMetaData({ - ...serialized, - deleted_frames: Object.fromEntries(serialized.deleted_frames.map((_frame) => [_frame, true])), + // When we get new framesMetaData from server there can be some unsaved data + // here we merge new meta data with cached one + mergeMetaData(serialized, previousCache).then((mergedData) => { + resolve(mergedData); }); - resolve(framesMetaData); }).catch((error: unknown) => { delete frameMetaCache[id]; if (previousCache instanceof Promise) { @@ -588,8 +638,9 @@ function saveJobMeta(meta: FramesMetaData, jobID: number): Promise { + const { mode, jobStartFrame } = frameDataCache[jobID]; + const meta = await frameDataCache[jobID].getMeta(); let frameMeta = null; if (mode === 'interpolation' && meta.frames.length === 1) { // video tasks have 1 frame info, but image tasks will have many infos @@ -616,12 +667,12 @@ async function refreshJobCacheIfOutdated(jobID: number): Promise { if (isOutdated) { // get metadata again if outdated + const prevMeta = await cached.getMeta(); const meta = await getFramesMeta('job', jobID, true); - if (new Date(meta.chunksUpdatedDate) > new Date(cached.meta.chunksUpdatedDate)) { + if (new Date(meta.chunksUpdatedDate) > new Date(prevMeta.chunksUpdatedDate)) { // chunks were re-defined. Existing data not relevant anymore // currently we only re-write meta, remove all cached frames from provider and clear cached context images // other parameters (e.g. chunkSize) are not supposed to be changed - cached.meta = meta; cached.provider.cleanup(Number.MAX_SAFE_INTEGER); for (const frame of Object.keys(cached.contextCache)) { for (const image of Object.values(cached.contextCache[+frame].data)) { @@ -636,7 +687,12 @@ async function refreshJobCacheIfOutdated(jobID: number): Promise { } } -export function getContextImage(jobID: number, frame: number): Promise> { +export async function getContextImage(jobID: number, frame: number): Promise> { + const frameData = frameDataCache[jobID]; + const meta = await frameData.getMeta(); + const requestId = frame; + const { jobStartFrame } = frameData; + const { related_files: relatedFiles } = meta.frames[frame - jobStartFrame]; return new Promise>((resolve, reject) => { if (!(jobID in frameDataCache)) { reject(new Error( @@ -644,11 +700,6 @@ export function getContextImage(jobID: number, frame: number): Promise { + const cached = frameMetaCache[jobID]; + if (!(cached instanceof Promise)) { + throw new Error('Frame meta data is not initialized'); + } + return cached; + }, }; } @@ -803,25 +860,27 @@ export async function getFrame( // Thus, it is better to only call `refreshJobCacheIfOutdated` from getFrame() await refreshJobCacheIfOutdated(jobID); - const frameMeta = getFrameMeta(jobID, frame); + const frameMeta = await getFrameMeta(jobID, frame); frameDataCache[jobID].provider.setRenderSize(frameMeta.width, frameMeta.height); frameDataCache[jobID].decodeForward = isPlaying; frameDataCache[jobID].forwardStep = step; + const meta = await frameDataCache[jobID].getMeta(); + return new FrameData({ width: frameMeta.width, height: frameMeta.height, name: frameMeta.name, related_files: frameMeta.related_files, frameNumber: frame, - deleted: frame in frameDataCache[jobID].meta.deletedFrames, + deleted: frame in meta.deletedFrames, jobID, }); } export async function getDeletedFrames(instanceType: 'job' | 'task', id: number): Promise> { if (instanceType === 'job') { - const { meta } = frameDataCache[id]; + const meta = await frameDataCache[id].getMeta(); return meta.deletedFrames; } @@ -900,12 +959,13 @@ export function getCachedChunks(jobID: number): number[] { return frameDataCache[jobID].provider.cachedChunks(true); } -export function getJobFrameNumbers(jobID: number): number[] { +export async function getJobFrameNumbers(jobID: number): Promise { if (!(jobID in frameDataCache)) { return []; } - const { meta, jobStartFrame } = frameDataCache[jobID]; + const { jobStartFrame } = frameDataCache[jobID]; + const meta = await frameDataCache[jobID].getMeta(); return meta.getSegmentFrameNumbers(jobStartFrame); } diff --git a/cvat-core/src/session-implementation.ts b/cvat-core/src/session-implementation.ts index 7ea9e326fb8b..a5c008605749 100644 --- a/cvat-core/src/session-implementation.ts +++ b/cvat-core/src/session-implementation.ts @@ -265,7 +265,7 @@ export function implementJob(Job: typeof JobClass): typeof JobClass { value: function includedFramesImplementation( this: JobClass, ): ReturnType { - return Promise.resolve(getJobFrameNumbers(this.id)); + return getJobFrameNumbers(this.id); }, }); From 279db7c45738ce45afac2755653667509adc019b Mon Sep 17 00:00:00 2001 From: Oleg Valiulin Date: Mon, 30 Dec 2024 12:55:06 +0000 Subject: [PATCH 16/31] fixing broken unit tests in cvat/app/events (#8867) Includes and closes https://github.com/cvat-ai/cvat/issues/8339, also closes https://github.com/cvat-ai/cvat/issues/8349 This adressess issue #8349. Necessary changes proposed in #8339 are blocked by runtime failures in cvat/apps/events unit-testing suite. The tests rely on functionality that was made obsolete after changes made in #7958. - added `__init__.py` files as proposed in #8339 - made constants from `cvat/apps/events/handler.py::handle_client_events_push` available to both the handler and the unit test, renamed some of them - refactored the test to rely on extracting working time information from send:working_time event instead of getting it from ClientEventsSerializer - refactored working time retrieval logic from handle_client_events_push to be checked and reused in unit tests - updated tests to calculate event working times cumulatively for each event and check the calculations against the already implemented assertions ### Motivation and context ### How has this been tested? Unit tests on events were run both locally and inside CI ### Checklist - [ ] I submit my changes into the `develop` branch - [ ] I have created a changelog fragment - [ ] I have updated the documentation accordingly - [ ] I have added tests to cover my changes - [ ] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) - [ ] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/cvat-ai/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/cvat-ai/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/cvat-ai/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/cvat-ai/cvat/tree/develop/cvat-ui#versioning)) ### License - [ ] I submit _my code changes_ under the same [MIT License]( https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. ## Summary by CodeRabbit - **New Features** - Introduced new constants for time management: `TIME_THRESHOLD` (100 seconds) and `WORKING_TIME_RESOLUTION` (1 millisecond). - **Bug Fixes** - Updated event handling logic to improve working time calculations using the new constants. - **Tests** - Enhanced test cases to utilize the new constants for better clarity and maintainability. Updated method naming for improved readability. --------- Co-authored-by: Oleg Valiulin Co-authored-by: Maxim Zhiltsov --- .gitignore | 5 + cvat/apps/dataset_manager/formats/__init__.py | 0 cvat/apps/events/__init__.py | 0 cvat/apps/events/const.py | 10 ++ cvat/apps/events/handlers.py | 49 +------ cvat/apps/events/tests/test_events.py | 126 +++++++++++------- cvat/apps/events/utils.py | 53 ++++++++ 7 files changed, 149 insertions(+), 94 deletions(-) create mode 100644 cvat/apps/dataset_manager/formats/__init__.py create mode 100644 cvat/apps/events/__init__.py create mode 100644 cvat/apps/events/const.py diff --git a/.gitignore b/.gitignore index 2c5deb225eae..5ea2759c829e 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,11 @@ __pycache__ .coverage .husky/ .python-version +tmp*cvat/ +temp*/ + +# Ignore generated test files +docker-compose.tests.yml # Ignore npm logs file npm-debug.log* diff --git a/cvat/apps/dataset_manager/formats/__init__.py b/cvat/apps/dataset_manager/formats/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/cvat/apps/events/__init__.py b/cvat/apps/events/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/cvat/apps/events/const.py b/cvat/apps/events/const.py new file mode 100644 index 000000000000..35df51c7adb0 --- /dev/null +++ b/cvat/apps/events/const.py @@ -0,0 +1,10 @@ +# Copyright (C) 2024 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +import datetime + +MAX_EVENT_DURATION = datetime.timedelta(seconds=100) +WORKING_TIME_RESOLUTION = datetime.timedelta(milliseconds=1) +WORKING_TIME_SCOPE = 'send:working_time' +COMPRESSED_EVENT_SCOPES = frozenset(("change:frame",)) diff --git a/cvat/apps/events/handlers.py b/cvat/apps/events/handlers.py index 8f29f91d9a1a..753eb84dd0da 100644 --- a/cvat/apps/events/handlers.py +++ b/cvat/apps/events/handlers.py @@ -2,7 +2,6 @@ # # SPDX-License-Identifier: MIT -import datetime import traceback from typing import Any, Optional, Union @@ -31,6 +30,8 @@ from .cache import get_cache from .event import event_scope, record_server_event +from .const import WORKING_TIME_RESOLUTION, WORKING_TIME_SCOPE +from .utils import compute_working_time_per_ids def project_id(instance): @@ -619,53 +620,11 @@ def handle_viewset_exception(exc, context): return response + def handle_client_events_push(request, data: dict): - TIME_THRESHOLD = datetime.timedelta(seconds=100) - WORKING_TIME_SCOPE = 'send:working_time' - WORKING_TIME_RESOLUTION = datetime.timedelta(milliseconds=1) - COLLAPSED_EVENT_SCOPES = frozenset(("change:frame",)) org = request.iam_context["organization"] - def read_ids(event: dict) -> tuple[int | None, int | None, int | None]: - return event.get("job_id"), event.get("task_id"), event.get("project_id") - - def get_end_timestamp(event: dict) -> datetime.datetime: - if event["scope"] in COLLAPSED_EVENT_SCOPES: - return event["timestamp"] + datetime.timedelta(milliseconds=event["duration"]) - return event["timestamp"] - - if previous_event := data["previous_event"]: - previous_end_timestamp = get_end_timestamp(previous_event) - previous_ids = read_ids(previous_event) - elif data["events"]: - previous_end_timestamp = data["events"][0]["timestamp"] - previous_ids = read_ids(data["events"][0]) - - working_time_per_ids = {} - for event in data["events"]: - working_time = datetime.timedelta() - timestamp = event["timestamp"] - - if timestamp > previous_end_timestamp: - t_diff = timestamp - previous_end_timestamp - if t_diff < TIME_THRESHOLD: - working_time += t_diff - - previous_end_timestamp = timestamp - - end_timestamp = get_end_timestamp(event) - if end_timestamp > previous_end_timestamp: - working_time += end_timestamp - previous_end_timestamp - previous_end_timestamp = end_timestamp - - if previous_ids not in working_time_per_ids: - working_time_per_ids[previous_ids] = { - "value": datetime.timedelta(), - "timestamp": timestamp, - } - - working_time_per_ids[previous_ids]["value"] += working_time - previous_ids = read_ids(event) + working_time_per_ids = compute_working_time_per_ids(data) if data["events"]: common = { diff --git a/cvat/apps/events/tests/test_events.py b/cvat/apps/events/tests/test_events.py index 81b054171dce..71dcc1ea89b0 100644 --- a/cvat/apps/events/tests/test_events.py +++ b/cvat/apps/events/tests/test_events.py @@ -2,7 +2,6 @@ # # SPDX-License-Identifier: MIT -import json import unittest from datetime import datetime, timedelta, timezone from typing import Optional @@ -12,13 +11,15 @@ from cvat.apps.events.serializers import ClientEventsSerializer from cvat.apps.organizations.models import Organization +from cvat.apps.events.const import MAX_EVENT_DURATION, WORKING_TIME_RESOLUTION +from cvat.apps.events.utils import compute_working_time_per_ids, is_contained class WorkingTimeTestCase(unittest.TestCase): _START_TIMESTAMP = datetime(2024, 1, 1, 12) - _SHORT_GAP = ClientEventsSerializer._TIME_THRESHOLD - timedelta(milliseconds=1) - _SHORT_GAP_INT = _SHORT_GAP / ClientEventsSerializer._WORKING_TIME_RESOLUTION - _LONG_GAP = ClientEventsSerializer._TIME_THRESHOLD - _LONG_GAP_INT = _LONG_GAP / ClientEventsSerializer._WORKING_TIME_RESOLUTION + _SHORT_GAP = MAX_EVENT_DURATION - timedelta(milliseconds=1) + _SHORT_GAP_INT = _SHORT_GAP / WORKING_TIME_RESOLUTION + _LONG_GAP = MAX_EVENT_DURATION + _LONG_GAP_INT = _LONG_GAP / WORKING_TIME_RESOLUTION @staticmethod def _instant_event(timestamp: datetime) -> dict: @@ -33,16 +34,27 @@ def _compressed_event(timestamp: datetime, duration: timedelta) -> dict: return { "scope": "change:frame", "timestamp": timestamp.isoformat(), - "duration": duration // ClientEventsSerializer._WORKING_TIME_RESOLUTION, + "duration": duration // WORKING_TIME_RESOLUTION, } + @staticmethod - def _working_time(event: dict) -> int: - payload = json.loads(event["payload"]) - return payload["working_time"] + def _get_actual_working_times(data: dict) -> list[int]: + data_copy = data.copy() + working_times = [] + for event in data['events']: + data_copy['events'] = [event] + event_working_time = compute_working_time_per_ids(data_copy) + for working_time in event_working_time.values(): + working_times.append((working_time['value'] // WORKING_TIME_RESOLUTION)) + if data_copy['previous_event'] and is_contained(event, data_copy['previous_event']): + continue + data_copy['previous_event'] = event + return working_times + @staticmethod - def _deserialize(events: list[dict], previous_event: Optional[dict] = None) -> list[dict]: + def _deserialize(events: list[dict], previous_event: Optional[dict] = None) -> dict: request = RequestFactory().post("/api/events") request.user = get_user_model()(id=100, username="testuser", email="testuser@example.org") request.iam_context = { @@ -60,118 +72,134 @@ def _deserialize(events: list[dict], previous_event: Optional[dict] = None) -> l s.is_valid(raise_exception=True) - return s.validated_data["events"] + return s.validated_data def test_instant(self): - events = self._deserialize([ + data = self._deserialize([ self._instant_event(self._START_TIMESTAMP), ]) - self.assertEqual(self._working_time(events[0]), 0) + event_times = self._get_actual_working_times(data) + self.assertEqual(event_times[0], 0) def test_compressed(self): - events = self._deserialize([ + data = self._deserialize([ self._compressed_event(self._START_TIMESTAMP, self._LONG_GAP), ]) - self.assertEqual(self._working_time(events[0]), self._LONG_GAP_INT) + event_times = self._get_actual_working_times(data) + self.assertEqual(event_times[0], self._LONG_GAP_INT) def test_instants_with_short_gap(self): - events = self._deserialize([ + data = self._deserialize([ self._instant_event(self._START_TIMESTAMP), self._instant_event(self._START_TIMESTAMP + self._SHORT_GAP), ]) - self.assertEqual(self._working_time(events[0]), 0) - self.assertEqual(self._working_time(events[1]), self._SHORT_GAP_INT) + event_times = self._get_actual_working_times(data) + self.assertEqual(event_times[0], 0) + self.assertEqual(event_times[1], self._SHORT_GAP_INT) def test_instants_with_long_gap(self): - events = self._deserialize([ + data = self._deserialize([ self._instant_event(self._START_TIMESTAMP), self._instant_event(self._START_TIMESTAMP + self._LONG_GAP), ]) - self.assertEqual(self._working_time(events[0]), 0) - self.assertEqual(self._working_time(events[1]), 0) + + event_times = self._get_actual_working_times(data) + self.assertEqual(event_times[0], 0) + self.assertEqual(event_times[1], 0) def test_compressed_with_short_gap(self): - events = self._deserialize([ + data = self._deserialize([ self._compressed_event(self._START_TIMESTAMP, timedelta(seconds=1)), self._compressed_event( self._START_TIMESTAMP + timedelta(seconds=1) + self._SHORT_GAP, timedelta(seconds=5) ), ]) - self.assertEqual(self._working_time(events[0]), 1000) - self.assertEqual(self._working_time(events[1]), self._SHORT_GAP_INT + 5000) + + event_times = self._get_actual_working_times(data) + self.assertEqual(event_times[0], 1000) + self.assertEqual(event_times[1], self._SHORT_GAP_INT + 5000) def test_compressed_with_long_gap(self): - events = self._deserialize([ + data = self._deserialize([ self._compressed_event(self._START_TIMESTAMP, timedelta(seconds=1)), self._compressed_event( self._START_TIMESTAMP + timedelta(seconds=1) + self._LONG_GAP, timedelta(seconds=5) ), ]) - self.assertEqual(self._working_time(events[0]), 1000) - self.assertEqual(self._working_time(events[1]), 5000) + + event_times = self._get_actual_working_times(data) + self.assertEqual(event_times[0], 1000) + self.assertEqual(event_times[1], 5000) def test_compressed_contained(self): - events = self._deserialize([ + data = self._deserialize([ self._compressed_event(self._START_TIMESTAMP, timedelta(seconds=5)), self._compressed_event( self._START_TIMESTAMP + timedelta(seconds=3), timedelta(seconds=1) ), ]) - self.assertEqual(self._working_time(events[0]), 5000) - self.assertEqual(self._working_time(events[1]), 0) + + event_times = self._get_actual_working_times(data) + self.assertEqual(event_times[0], 5000) + self.assertEqual(event_times[1], 0) def test_compressed_overlapping(self): - events = self._deserialize([ + data = self._deserialize([ self._compressed_event(self._START_TIMESTAMP, timedelta(seconds=5)), self._compressed_event( self._START_TIMESTAMP + timedelta(seconds=3), timedelta(seconds=6) ), ]) - self.assertEqual(self._working_time(events[0]), 5000) - self.assertEqual(self._working_time(events[1]), 4000) + + event_times = self._get_actual_working_times(data) + self.assertEqual(event_times[0], 5000) + self.assertEqual(event_times[1], 4000) def test_instant_inside_compressed(self): - events = self._deserialize([ + data = self._deserialize([ self._compressed_event(self._START_TIMESTAMP, timedelta(seconds=5)), self._instant_event(self._START_TIMESTAMP + timedelta(seconds=3)), self._instant_event(self._START_TIMESTAMP + timedelta(seconds=6)), ]) - self.assertEqual(self._working_time(events[0]), 5000) - self.assertEqual(self._working_time(events[1]), 0) - self.assertEqual(self._working_time(events[2]), 1000) + + event_times = self._get_actual_working_times(data) + self.assertEqual(event_times[0], 5000) + self.assertEqual(event_times[1], 0) + self.assertEqual(event_times[2], 1000) + def test_previous_instant_short_gap(self): - events = self._deserialize( + data = self._deserialize( [self._instant_event(self._START_TIMESTAMP + self._SHORT_GAP)], previous_event=self._instant_event(self._START_TIMESTAMP), ) - - self.assertEqual(self._working_time(events[0]), self._SHORT_GAP_INT) + event_times = self._get_actual_working_times(data) + self.assertEqual(event_times[0], self._SHORT_GAP_INT) def test_previous_instant_long_gap(self): - events = self._deserialize( + data = self._deserialize( [self._instant_event(self._START_TIMESTAMP + self._LONG_GAP)], previous_event=self._instant_event(self._START_TIMESTAMP), ) - - self.assertEqual(self._working_time(events[0]), 0) + event_times = self._get_actual_working_times(data) + self.assertEqual(event_times[0], 0) def test_previous_compressed_short_gap(self): - events = self._deserialize( + data = self._deserialize( [self._instant_event(self._START_TIMESTAMP + timedelta(seconds=1) + self._SHORT_GAP)], previous_event=self._compressed_event(self._START_TIMESTAMP, timedelta(seconds=1)), ) - - self.assertEqual(self._working_time(events[0]), self._SHORT_GAP_INT) + event_times = self._get_actual_working_times(data) + self.assertEqual(event_times[0], self._SHORT_GAP_INT) def test_previous_compressed_long_gap(self): - events = self._deserialize( + data = self._deserialize( [self._instant_event(self._START_TIMESTAMP + timedelta(seconds=1) + self._LONG_GAP)], previous_event=self._compressed_event(self._START_TIMESTAMP, timedelta(seconds=1)), ) - - self.assertEqual(self._working_time(events[0]), 0) + event_times = self._get_actual_working_times(data) + self.assertEqual(event_times[0], 0) diff --git a/cvat/apps/events/utils.py b/cvat/apps/events/utils.py index f5ef6dde4295..745bb8fde316 100644 --- a/cvat/apps/events/utils.py +++ b/cvat/apps/events/utils.py @@ -2,8 +2,13 @@ # # SPDX-License-Identifier: MIT +import datetime + + +from .const import MAX_EVENT_DURATION, COMPRESSED_EVENT_SCOPES from .cache import clear_cache + def _prepare_objects_to_delete(object_to_delete): from cvat.apps.engine.models import Project, Task, Segment, Job, Issue, Comment @@ -63,3 +68,51 @@ def wrap(self, *args, **kwargs): finally: clear_cache() return wrap + + +def get_end_timestamp(event: dict) -> datetime.datetime: + if event["scope"] in COMPRESSED_EVENT_SCOPES: + return event["timestamp"] + datetime.timedelta(milliseconds=event["duration"]) + return event["timestamp"] + +def is_contained(event1: dict, event2: dict) -> bool: + return event1['timestamp'] < get_end_timestamp(event2) + +def compute_working_time_per_ids(data: dict) -> dict: + def read_ids(event: dict) -> tuple[int | None, int | None, int | None]: + return event.get("job_id"), event.get("task_id"), event.get("project_id") + + if previous_event := data["previous_event"]: + previous_end_timestamp = get_end_timestamp(previous_event) + previous_ids = read_ids(previous_event) + elif data["events"]: + previous_end_timestamp = data["events"][0]["timestamp"] + previous_ids = read_ids(data["events"][0]) + + working_time_per_ids = {} + for event in data["events"]: + working_time = datetime.timedelta() + timestamp = event["timestamp"] + + if timestamp > previous_end_timestamp: + t_diff = timestamp - previous_end_timestamp + if t_diff < MAX_EVENT_DURATION: + working_time += t_diff + + previous_end_timestamp = timestamp + + end_timestamp = get_end_timestamp(event) + if end_timestamp > previous_end_timestamp: + working_time += end_timestamp - previous_end_timestamp + previous_end_timestamp = end_timestamp + + if previous_ids not in working_time_per_ids: + working_time_per_ids[previous_ids] = { + "value": datetime.timedelta(), + "timestamp": timestamp, + } + + working_time_per_ids[previous_ids]["value"] += working_time + previous_ids = read_ids(event) + + return working_time_per_ids From c80091ba9c736b064624eaf4b4fea248a7b1e0db Mon Sep 17 00:00:00 2001 From: Andrey Zhavoronkov Date: Fri, 3 Jan 2025 15:04:28 +0300 Subject: [PATCH 17/31] Fix unstable headlessCreateUser cypress command (#8885) ### Motivation and context ### How has this been tested? ### Checklist - [x] I submit my changes into the `develop` branch - [ ] I have created a changelog fragment - [ ] I have updated the documentation accordingly - [ ] I have added tests to cover my changes - [ ] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) - [ ] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/cvat-ai/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/cvat-ai/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/cvat-ai/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/cvat-ai/cvat/tree/develop/cvat-ui#versioning)) ### License - [x] I submit _my code changes_ under the same [MIT License]( https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. ## Summary by CodeRabbit - **New Features** - Enhanced user creation command with improved error handling and validation. - Added assertions to verify successful user registration and email correctness. --------- Co-authored-by: Kirill Lakhov --- .../e2e/actions_projects_models/markdown_base_pipeline.js | 4 ++-- .../case_28_review_pipeline_feature.js | 4 ++-- tests/cypress/support/commands.js | 6 +++++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/cypress/e2e/actions_projects_models/markdown_base_pipeline.js b/tests/cypress/e2e/actions_projects_models/markdown_base_pipeline.js index 51143c318794..639e57ad09f1 100644 --- a/tests/cypress/e2e/actions_projects_models/markdown_base_pipeline.js +++ b/tests/cypress/e2e/actions_projects_models/markdown_base_pipeline.js @@ -14,14 +14,14 @@ context('Basic markdown pipeline', () => { username: 'md_job_assignee', firstName: 'Firstname', lastName: 'Lastname', - emailAddr: 'md_job_assignee@local.local', + email: 'md_job_assignee@local.local', password: 'Fv5Df3#f55g', }, taskAssignee: { username: 'md_task_assignee', firstName: 'Firstname', lastName: 'Lastname', - emailAddr: 'md_task_assignee@local.local', + email: 'md_task_assignee@local.local', password: 'UfdU21!dds', }, notAssignee: { diff --git a/tests/cypress/e2e/actions_users/registration_involved/case_28_review_pipeline_feature.js b/tests/cypress/e2e/actions_users/registration_involved/case_28_review_pipeline_feature.js index c77e00df3ad1..464464492832 100644 --- a/tests/cypress/e2e/actions_users/registration_involved/case_28_review_pipeline_feature.js +++ b/tests/cypress/e2e/actions_users/registration_involved/case_28_review_pipeline_feature.js @@ -12,14 +12,14 @@ context('Review pipeline feature', () => { username: 'annotator', firstName: 'Firstname', lastName: 'Lastname', - emailAddr: 'annotator@local.local', + email: 'annotator@local.local', password: 'UfdU21!dds', }, reviewer: { username: 'reviewer', firstName: 'Firstname', lastName: 'Lastname', - emailAddr: 'reviewer@local.local', + email: 'reviewer@local.local', password: 'Fv5Df3#f55g', }, }; diff --git a/tests/cypress/support/commands.js b/tests/cypress/support/commands.js index 42b7d2772375..a027c260e7b0 100644 --- a/tests/cypress/support/commands.js +++ b/tests/cypress/support/commands.js @@ -360,8 +360,12 @@ Cypress.Commands.add('headlessCreateUser', (userSpec) => { headers: { 'Content-type': 'application/json', }, + }).then((response) => { + expect(response.status).to.eq(201); + expect(response.body.username).to.eq(userSpec.username); + expect(response.body.email).to.eq(userSpec.email); + return cy.wrap(); }); - return cy.wrap(); }); Cypress.Commands.add('headlessLogout', () => { From 76de839ab25ee8c8fc74da499065214397786cd7 Mon Sep 17 00:00:00 2001 From: Roman Donchenko Date: Mon, 6 Jan 2025 15:55:26 +0200 Subject: [PATCH 18/31] Apply isort to almost all Python sources (#8866) isort has a `--resolve-all-configs` options that allows you to use different configs for different subdirectories. With this option, we can reformat/check the whole codebase with one command. So let's just do that. It's a little janky, because isort's first party module detection doesn't seem to work properly when you run it from the project root and not the appropriate subdirectory. So I had to patch that up with explicit `known_first_party` settings. I also didn't feel like doing that for all of the serverless functions, so I just added them to the ignore list for now. --- .github/workflows/isort.yml | 30 +-- cvat-sdk/pyproject.toml | 1 + cvat/apps/dataset_manager/annotation.py | 10 +- cvat/apps/dataset_manager/bindings.py | 28 ++- cvat/apps/dataset_manager/formats/camvid.py | 5 +- .../dataset_manager/formats/cityscapes.py | 9 +- cvat/apps/dataset_manager/formats/coco.py | 8 +- cvat/apps/dataset_manager/formats/cvat.py | 23 ++- cvat/apps/dataset_manager/formats/datumaro.py | 6 +- cvat/apps/dataset_manager/formats/icdar.py | 8 +- cvat/apps/dataset_manager/formats/imagenet.py | 3 +- cvat/apps/dataset_manager/formats/kitti.py | 9 +- cvat/apps/dataset_manager/formats/labelme.py | 7 +- cvat/apps/dataset_manager/formats/lfw.py | 7 +- .../dataset_manager/formats/market1501.py | 11 +- cvat/apps/dataset_manager/formats/mask.py | 10 +- cvat/apps/dataset_manager/formats/mots.py | 10 +- .../dataset_manager/formats/openimages.py | 11 +- .../dataset_manager/formats/pascal_voc.py | 6 +- .../dataset_manager/formats/pointcloud.py | 7 +- .../formats/transformations.py | 6 +- cvat/apps/dataset_manager/formats/utils.py | 5 +- .../dataset_manager/formats/velodynepoint.py | 12 +- cvat/apps/dataset_manager/formats/vggface2.py | 8 +- .../apps/dataset_manager/formats/widerface.py | 7 +- cvat/apps/dataset_manager/formats/yolo.py | 8 +- cvat/apps/dataset_manager/project.py | 12 +- cvat/apps/dataset_manager/task.py | 27 ++- .../dataset_manager/tests/test_formats.py | 17 +- .../tests/test_rest_api_formats.py | 15 +- cvat/apps/dataset_manager/views.py | 16 +- .../dataset_repo/migrations/0001_initial.py | 2 +- .../dataset_repo/migrations/0004_rename.py | 1 + cvat/apps/engine/__init__.py | 2 +- cvat/apps/engine/admin.py | 17 +- cvat/apps/engine/apps.py | 1 + cvat/apps/engine/backup.py | 58 ++++-- cvat/apps/engine/cloud_provider.py | 10 +- cvat/apps/engine/filters.py | 18 +- cvat/apps/engine/frame_provider.py | 10 +- cvat/apps/engine/handlers.py | 2 + cvat/apps/engine/location.py | 5 +- cvat/apps/engine/log.py | 7 +- .../management/commands/syncperiodicjobs.py | 4 +- cvat/apps/engine/media_extractors.py | 19 +- cvat/apps/engine/middleware.py | 1 + .../engine/migrations/0001_release_v0_1_0.py | 2 +- ...dpolygonattributeval_labeledpolyline_la.py | 2 +- .../migrations/0008_auto_20180917_1424.py | 2 +- .../0011_add_task_source_and_safecharfield.py | 3 +- .../0013_auth_no_default_permissions.py | 2 +- .../migrations/0015_db_redesign_20190217.py | 6 +- .../0016_attribute_spec_20190217.py | 9 +- .../migrations/0017_db_redesign_20190221.py | 6 +- cvat/apps/engine/migrations/0018_jobcommit.py | 2 +- .../migrations/0020_remove_task_flipped.py | 11 +- .../migrations/0022_auto_20191004_0817.py | 5 +- .../migrations/0023_auto_20200113_1323.py | 3 +- .../migrations/0024_auto_20191023_1025.py | 26 ++- .../apps/engine/migrations/0028_labelcolor.py | 2 + .../migrations/0029_data_storage_method.py | 9 +- .../migrations/0033_projects_adjastment.py | 2 +- .../migrations/0034_auto_20201125_1426.py | 6 +- .../engine/migrations/0035_data_storage.py | 3 +- .../migrations/0036_auto_20201216_0943.py | 5 +- cvat/apps/engine/migrations/0038_manifest.py | 3 +- .../engine/migrations/0039_auto_training.py | 2 +- .../engine/migrations/0040_cloud_storage.py | 5 +- .../migrations/0042_auto_20210830_1056.py | 2 +- .../migrations/0046_data_sorting_method.py | 3 +- .../migrations/0047_auto_20211110_1938.py | 5 +- .../migrations/0048_auto_20211112_1918.py | 2 +- .../migrations/0053_data_deleted_frames.py | 3 +- .../migrations/0054_auto_20220610_1829.py | 5 +- .../migrations/0055_jobs_directories.py | 3 +- .../engine/migrations/0056_jobs_previews.py | 4 +- .../migrations/0057_auto_20220726_0926.py | 5 +- .../migrations/0058_auto_20220809_1236.py | 2 +- .../migrations/0060_alter_label_parent.py | 2 +- .../engine/migrations/0062_delete_previews.py | 4 +- .../0064_delete_or_rename_wrong_labels.py | 2 + .../0070_add_job_type_created_date.py | 5 +- .../migrations/0071_annotationguide_asset.py | 5 +- .../0072_alter_issue_updated_date.py | 1 + ...es_that_refer_to_deleted_cloud_storages.py | 1 + .../migrations/0077_auto_20231121_1952.py | 2 +- ...labeledimageattributeval_image_and_more.py | 2 +- .../0080_alter_trackedshape_track.py | 2 +- .../0082_alter_labeledimage_job_and_more.py | 2 +- .../0085_segment_chunks_updated_date.py | 1 + cvat/apps/engine/mime_types.py | 3 +- cvat/apps/engine/mixins.py | 14 +- cvat/apps/engine/pagination.py | 2 + cvat/apps/engine/parsers.py | 1 + cvat/apps/engine/permissions.py | 13 +- cvat/apps/engine/renderers.py | 1 + cvat/apps/engine/rq_job_handler.py | 9 +- cvat/apps/engine/serializers.py | 48 +++-- cvat/apps/engine/signals.py | 3 +- cvat/apps/engine/task.py | 40 ++-- cvat/apps/engine/tests/test_lazy_list.py | 4 +- cvat/apps/engine/tests/test_rest_api.py | 48 +++-- cvat/apps/engine/tests/test_rest_api_3D.py | 9 +- cvat/apps/engine/tests/utils.py | 12 +- cvat/apps/engine/urls.py | 11 +- cvat/apps/engine/utils.py | 47 +++-- cvat/apps/engine/view_utils.py | 2 +- cvat/apps/engine/views.py | 182 ++++++++++++------ cvat/apps/events/apps.py | 4 +- cvat/apps/events/cache.py | 2 +- cvat/apps/events/event.py | 3 +- cvat/apps/events/export.py | 14 +- cvat/apps/events/handlers.py | 42 ++-- cvat/apps/events/permissions.py | 2 +- cvat/apps/events/tests/test_events.py | 5 +- cvat/apps/events/utils.py | 5 +- cvat/apps/events/views.py | 3 +- cvat/apps/health/apps.py | 2 +- cvat/apps/health/backends.py | 5 +- .../health/management/commands/workerprobe.py | 5 +- cvat/apps/iam/adapters.py | 4 +- cvat/apps/iam/admin.py | 2 +- cvat/apps/iam/apps.py | 1 + cvat/apps/iam/authentication.py | 8 +- cvat/apps/iam/filters.py | 4 +- cvat/apps/iam/forms.py | 7 +- cvat/apps/iam/middleware.py | 4 +- .../migrations/0001_remove_business_group.py | 1 - cvat/apps/iam/rules/tests/generate_tests.py | 2 +- cvat/apps/iam/schema.py | 1 - cvat/apps/iam/serializers.py | 18 +- cvat/apps/iam/signals.py | 5 +- cvat/apps/iam/tests/test_rest_api.py | 10 +- cvat/apps/iam/urls.py | 20 +- cvat/apps/iam/utils.py | 4 +- cvat/apps/iam/views.py | 35 ++-- cvat/apps/lambda_manager/models.py | 1 + cvat/apps/lambda_manager/permissions.py | 1 + cvat/apps/lambda_manager/serializers.py | 1 + cvat/apps/lambda_manager/tests/test_lambda.py | 10 +- cvat/apps/lambda_manager/views.py | 33 ++-- cvat/apps/log_viewer/views.py | 2 +- cvat/apps/organizations/admin.py | 4 +- cvat/apps/organizations/apps.py | 1 + .../organizations/migrations/0001_initial.py | 2 +- cvat/apps/organizations/models.py | 13 +- cvat/apps/organizations/permissions.py | 1 + cvat/apps/organizations/serializers.py | 10 +- cvat/apps/organizations/throttle.py | 1 + cvat/apps/organizations/urls.py | 1 + cvat/apps/organizations/views.py | 33 ++-- cvat/apps/webhooks/apps.py | 4 +- cvat/apps/webhooks/migrations/0001_initial.py | 5 +- cvat/apps/webhooks/permissions.py | 2 +- cvat/apps/webhooks/serializers.py | 9 +- cvat/apps/webhooks/signals.py | 16 +- cvat/apps/webhooks/urls.py | 1 + cvat/apps/webhooks/views.py | 18 +- cvat/settings/base.py | 5 +- cvat/settings/email_settings.py | 1 - cvat/settings/testing.py | 7 +- cvat/urls.py | 2 +- cvat/utils/http.py | 3 +- dev/update_version.py | 1 - pyproject.toml | 8 + rqscheduler.py | 4 +- tests/python/pyproject.toml | 1 + utils/dataset_manifest/__init__.py | 2 +- utils/dataset_manifest/core.py | 17 +- utils/dataset_manifest/create.py | 7 +- utils/dataset_manifest/types.py | 1 + utils/dataset_manifest/utils.py | 10 +- utils/dicom_converter/script.py | 5 +- 173 files changed, 951 insertions(+), 637 deletions(-) diff --git a/.github/workflows/isort.yml b/.github/workflows/isort.yml index bf90604cbb2f..620dc6c85d79 100644 --- a/.github/workflows/isort.yml +++ b/.github/workflows/isort.yml @@ -5,35 +5,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - id: files - uses: tj-actions/changed-files@v41.0.0 - with: - files: | - cvat-sdk/**/*.py - cvat-cli/**/*.py - tests/python/**/*.py - cvat/apps/quality_control/**/*.py - cvat/apps/analytics_report/**/*.py - dir_names: true - name: Run checks run: | - # If different modules use different isort configs, - # we need to run isort for each python component group separately. - # Otherwise, they all will use the same config. + pipx install $(grep "^isort" ./dev/requirements.txt) - UPDATED_DIRS="${{steps.files.outputs.all_changed_files}}" + echo "isort version: $(isort --version-number)" - if [[ ! -z $UPDATED_DIRS ]]; then - pipx install $(grep "^isort" ./dev/requirements.txt) - - echo "isort version: $(isort --version-number)" - echo "The dirs will be checked: $UPDATED_DIRS" - EXIT_CODE=0 - for DIR in $UPDATED_DIRS; do - isort --check $DIR || EXIT_CODE=$(($? | $EXIT_CODE)) || true - done - exit $EXIT_CODE - else - echo "No files with the \"py\" extension found" - fi + isort --check --diff --resolve-all-configs . diff --git a/cvat-sdk/pyproject.toml b/cvat-sdk/pyproject.toml index ce8cba3ffba6..8d3fb7787504 100644 --- a/cvat-sdk/pyproject.toml +++ b/cvat-sdk/pyproject.toml @@ -7,3 +7,4 @@ profile = "black" forced_separate = ["tests"] line_length = 100 skip_gitignore = true # align tool behavior with Black +known_first_party = ["cvat_sdk"] diff --git a/cvat/apps/dataset_manager/annotation.py b/cvat/apps/dataset_manager/annotation.py index 4ea10ba9619d..943e53d003e3 100644 --- a/cvat/apps/dataset_manager/annotation.py +++ b/cvat/apps/dataset_manager/annotation.py @@ -3,19 +3,19 @@ # # SPDX-License-Identifier: MIT -from copy import copy, deepcopy - import math from collections.abc import Container, Sequence +from copy import copy, deepcopy +from itertools import chain from typing import Optional + import numpy as np -from itertools import chain from scipy.optimize import linear_sum_assignment from shapely import geometry -from cvat.apps.engine.models import ShapeType, DimensionType -from cvat.apps.engine.serializers import LabeledDataSerializer from cvat.apps.dataset_manager.util import faster_deepcopy +from cvat.apps.engine.models import DimensionType, ShapeType +from cvat.apps.engine.serializers import LabeledDataSerializer class AnnotationIR: diff --git a/cvat/apps/dataset_manager/bindings.py b/cvat/apps/dataset_manager/bindings.py index 8b759f7b6316..a4688d80ba5a 100644 --- a/cvat/apps/dataset_manager/bindings.py +++ b/cvat/apps/dataset_manager/bindings.py @@ -16,29 +16,39 @@ from types import SimpleNamespace from typing import Any, Callable, Literal, NamedTuple, Optional, Union -from attrs.converters import to_bool import datumaro as dm import defusedxml.ElementTree as ET import rq from attr import attrib, attrs +from attrs.converters import to_bool from datumaro.components.format_detection import RejectionReason +from django.conf import settings from django.db.models import Prefetch, QuerySet from django.utils import timezone -from django.conf import settings from cvat.apps.dataset_manager.formats.utils import get_label_color from cvat.apps.dataset_manager.util import add_prefetch_fields from cvat.apps.engine import models -from cvat.apps.engine.frame_provider import TaskFrameProvider, FrameQuality, FrameOutputType -from cvat.apps.engine.models import (AttributeSpec, AttributeType, DimensionType, Job, - JobType, Label, LabelType, Project, SegmentType, ShapeType, - Task) -from cvat.apps.engine.rq_job_handler import RQJobMetaField +from cvat.apps.engine.frame_provider import FrameOutputType, FrameQuality, TaskFrameProvider from cvat.apps.engine.lazy_list import LazyList +from cvat.apps.engine.models import ( + AttributeSpec, + AttributeType, + DimensionType, + Job, + JobType, + Label, + LabelType, + Project, + SegmentType, + ShapeType, + Task, +) +from cvat.apps.engine.rq_job_handler import RQJobMetaField -from .annotation import AnnotationIR, AnnotationManager, TrackManager -from .formats.transformations import MaskConverter, EllipsesToMasks from ..engine.log import ServerLogManager +from .annotation import AnnotationIR, AnnotationManager, TrackManager +from .formats.transformations import EllipsesToMasks, MaskConverter slogger = ServerLogManager(__name__) diff --git a/cvat/apps/dataset_manager/formats/camvid.py b/cvat/apps/dataset_manager/formats/camvid.py index 75cea9e98bd4..e995c5f1075d 100644 --- a/cvat/apps/dataset_manager/formats/camvid.py +++ b/cvat/apps/dataset_manager/formats/camvid.py @@ -6,12 +6,11 @@ from datumaro.components.dataset import Dataset from pyunpack import Archive -from cvat.apps.dataset_manager.bindings import (GetCVATDataExtractor, - import_dm_annotations) +from cvat.apps.dataset_manager.bindings import GetCVATDataExtractor, import_dm_annotations from cvat.apps.dataset_manager.util import make_zip_archive -from .transformations import MaskToPolygonTransformation, RotatedBoxesToPolygons from .registry import dm_env, exporter, importer +from .transformations import MaskToPolygonTransformation, RotatedBoxesToPolygons from .utils import make_colormap diff --git a/cvat/apps/dataset_manager/formats/cityscapes.py b/cvat/apps/dataset_manager/formats/cityscapes.py index ea39578ea3f3..6de867e551c6 100644 --- a/cvat/apps/dataset_manager/formats/cityscapes.py +++ b/cvat/apps/dataset_manager/formats/cityscapes.py @@ -9,12 +9,15 @@ from datumaro.plugins.cityscapes_format import write_label_map from pyunpack import Archive -from cvat.apps.dataset_manager.bindings import (GetCVATDataExtractor, detect_dataset, - import_dm_annotations) +from cvat.apps.dataset_manager.bindings import ( + GetCVATDataExtractor, + detect_dataset, + import_dm_annotations, +) from cvat.apps.dataset_manager.util import make_zip_archive -from .transformations import MaskToPolygonTransformation, RotatedBoxesToPolygons from .registry import dm_env, exporter, importer +from .transformations import MaskToPolygonTransformation, RotatedBoxesToPolygons from .utils import make_colormap diff --git a/cvat/apps/dataset_manager/formats/coco.py b/cvat/apps/dataset_manager/formats/coco.py index 1d1a8ce4d0d5..bddb33b08ba9 100644 --- a/cvat/apps/dataset_manager/formats/coco.py +++ b/cvat/apps/dataset_manager/formats/coco.py @@ -5,17 +5,21 @@ import zipfile -from datumaro.components.dataset import Dataset from datumaro.components.annotation import AnnotationType +from datumaro.components.dataset import Dataset from datumaro.plugins.coco_format.importer import CocoImporter from cvat.apps.dataset_manager.bindings import ( - GetCVATDataExtractor, NoMediaInAnnotationFileError, import_dm_annotations, detect_dataset + GetCVATDataExtractor, + NoMediaInAnnotationFileError, + detect_dataset, + import_dm_annotations, ) from cvat.apps.dataset_manager.util import make_zip_archive from .registry import dm_env, exporter, importer + @exporter(name='COCO', ext='ZIP', version='1.0') def _export(dst_file, temp_dir, instance_data, save_images=False): with GetCVATDataExtractor(instance_data, include_images=save_images) as extractor: diff --git a/cvat/apps/dataset_manager/formats/cvat.py b/cvat/apps/dataset_manager/formats/cvat.py index fa46b58813bf..58a8076f01cc 100644 --- a/cvat/apps/dataset_manager/formats/cvat.py +++ b/cvat/apps/dataset_manager/formats/cvat.py @@ -11,29 +11,34 @@ from io import BufferedWriter from typing import Callable, Union -from datumaro.components.annotation import (AnnotationType, Bbox, Label, - LabelCategories, Points, Polygon, - PolyLine, Skeleton) +from datumaro.components.annotation import ( + AnnotationType, + Bbox, + Label, + LabelCategories, + Points, + Polygon, + PolyLine, + Skeleton, +) from datumaro.components.dataset import Dataset, DatasetItem -from datumaro.components.extractor import (DEFAULT_SUBSET_NAME, Extractor, - Importer) +from datumaro.components.extractor import DEFAULT_SUBSET_NAME, Extractor, Importer from datumaro.plugins.cvat_format.extractor import CvatImporter as _CvatImporter - from datumaro.util.image import Image from defusedxml import ElementTree from cvat.apps.dataset_manager.bindings import ( + JobData, NoMediaInAnnotationFileError, ProjectData, TaskData, - JobData, detect_dataset, get_defaulted_subset, import_dm_annotations, - match_dm_item + match_dm_item, ) from cvat.apps.dataset_manager.util import make_zip_archive -from cvat.apps.engine.frame_provider import FrameQuality, FrameOutputType, make_frame_provider +from cvat.apps.engine.frame_provider import FrameOutputType, FrameQuality, make_frame_provider from .registry import dm_env, exporter, importer diff --git a/cvat/apps/dataset_manager/formats/datumaro.py b/cvat/apps/dataset_manager/formats/datumaro.py index 4fc1d246dd47..81f86cb32065 100644 --- a/cvat/apps/dataset_manager/formats/datumaro.py +++ b/cvat/apps/dataset_manager/formats/datumaro.py @@ -4,10 +4,14 @@ # SPDX-License-Identifier: MIT import zipfile + from datumaro.components.dataset import Dataset from cvat.apps.dataset_manager.bindings import ( - GetCVATDataExtractor, import_dm_annotations, NoMediaInAnnotationFileError, detect_dataset + GetCVATDataExtractor, + NoMediaInAnnotationFileError, + detect_dataset, + import_dm_annotations, ) from cvat.apps.dataset_manager.util import make_zip_archive from cvat.apps.engine.models import DimensionType diff --git a/cvat/apps/dataset_manager/formats/icdar.py b/cvat/apps/dataset_manager/formats/icdar.py index 5d031eef82b0..c72f9708fe11 100644 --- a/cvat/apps/dataset_manager/formats/icdar.py +++ b/cvat/apps/dataset_manager/formats/icdar.py @@ -5,17 +5,15 @@ import zipfile -from datumaro.components.annotation import (AnnotationType, Caption, Label, - LabelCategories) +from datumaro.components.annotation import AnnotationType, Caption, Label, LabelCategories from datumaro.components.dataset import Dataset from datumaro.components.extractor import ItemTransform -from cvat.apps.dataset_manager.bindings import (GetCVATDataExtractor, - import_dm_annotations) +from cvat.apps.dataset_manager.bindings import GetCVATDataExtractor, import_dm_annotations from cvat.apps.dataset_manager.util import make_zip_archive -from .transformations import MaskToPolygonTransformation, RotatedBoxesToPolygons from .registry import dm_env, exporter, importer +from .transformations import MaskToPolygonTransformation, RotatedBoxesToPolygons class AddLabelToAnns(ItemTransform): diff --git a/cvat/apps/dataset_manager/formats/imagenet.py b/cvat/apps/dataset_manager/formats/imagenet.py index fd5e9a99a176..273f47616bc1 100644 --- a/cvat/apps/dataset_manager/formats/imagenet.py +++ b/cvat/apps/dataset_manager/formats/imagenet.py @@ -9,8 +9,7 @@ from datumaro.components.dataset import Dataset -from cvat.apps.dataset_manager.bindings import GetCVATDataExtractor, \ - import_dm_annotations +from cvat.apps.dataset_manager.bindings import GetCVATDataExtractor, import_dm_annotations from cvat.apps.dataset_manager.util import make_zip_archive from .registry import dm_env, exporter, importer diff --git a/cvat/apps/dataset_manager/formats/kitti.py b/cvat/apps/dataset_manager/formats/kitti.py index 01e1cd3fc4bc..0bdfdc4ab6e6 100644 --- a/cvat/apps/dataset_manager/formats/kitti.py +++ b/cvat/apps/dataset_manager/formats/kitti.py @@ -7,14 +7,17 @@ from datumaro.components.dataset import Dataset from datumaro.plugins.kitti_format.format import KittiPath, write_label_map - from pyunpack import Archive -from cvat.apps.dataset_manager.bindings import (GetCVATDataExtractor, detect_dataset, import_dm_annotations) +from cvat.apps.dataset_manager.bindings import ( + GetCVATDataExtractor, + detect_dataset, + import_dm_annotations, +) from cvat.apps.dataset_manager.util import make_zip_archive -from .transformations import MaskToPolygonTransformation, RotatedBoxesToPolygons from .registry import dm_env, exporter, importer +from .transformations import MaskToPolygonTransformation, RotatedBoxesToPolygons from .utils import make_colormap diff --git a/cvat/apps/dataset_manager/formats/labelme.py b/cvat/apps/dataset_manager/formats/labelme.py index be9679f268e8..179fb320f322 100644 --- a/cvat/apps/dataset_manager/formats/labelme.py +++ b/cvat/apps/dataset_manager/formats/labelme.py @@ -6,8 +6,11 @@ from datumaro.components.dataset import Dataset from pyunpack import Archive -from cvat.apps.dataset_manager.bindings import (GetCVATDataExtractor, detect_dataset, - import_dm_annotations) +from cvat.apps.dataset_manager.bindings import ( + GetCVATDataExtractor, + detect_dataset, + import_dm_annotations, +) from cvat.apps.dataset_manager.formats.transformations import MaskToPolygonTransformation from cvat.apps.dataset_manager.util import make_zip_archive diff --git a/cvat/apps/dataset_manager/formats/lfw.py b/cvat/apps/dataset_manager/formats/lfw.py index 0af356332bb5..407240c5e0a3 100644 --- a/cvat/apps/dataset_manager/formats/lfw.py +++ b/cvat/apps/dataset_manager/formats/lfw.py @@ -6,8 +6,11 @@ from datumaro.components.dataset import Dataset from pyunpack import Archive -from cvat.apps.dataset_manager.bindings import (GetCVATDataExtractor, detect_dataset, - import_dm_annotations) +from cvat.apps.dataset_manager.bindings import ( + GetCVATDataExtractor, + detect_dataset, + import_dm_annotations, +) from cvat.apps.dataset_manager.util import make_zip_archive from .registry import dm_env, exporter, importer diff --git a/cvat/apps/dataset_manager/formats/market1501.py b/cvat/apps/dataset_manager/formats/market1501.py index 6be8b2fcf75f..e9d46a095bc8 100644 --- a/cvat/apps/dataset_manager/formats/market1501.py +++ b/cvat/apps/dataset_manager/formats/market1501.py @@ -5,17 +5,20 @@ import zipfile -from datumaro.components.annotation import (AnnotationType, Label, - LabelCategories) +from datumaro.components.annotation import AnnotationType, Label, LabelCategories from datumaro.components.dataset import Dataset from datumaro.components.extractor import ItemTransform -from cvat.apps.dataset_manager.bindings import (GetCVATDataExtractor, detect_dataset, - import_dm_annotations) +from cvat.apps.dataset_manager.bindings import ( + GetCVATDataExtractor, + detect_dataset, + import_dm_annotations, +) from cvat.apps.dataset_manager.util import make_zip_archive from .registry import dm_env, exporter, importer + class AttrToLabelAttr(ItemTransform): def __init__(self, extractor, label): super().__init__(extractor) diff --git a/cvat/apps/dataset_manager/formats/mask.py b/cvat/apps/dataset_manager/formats/mask.py index f003f68383e7..eab4238f4242 100644 --- a/cvat/apps/dataset_manager/formats/mask.py +++ b/cvat/apps/dataset_manager/formats/mask.py @@ -6,14 +6,18 @@ from datumaro.components.dataset import Dataset from pyunpack import Archive -from cvat.apps.dataset_manager.bindings import (GetCVATDataExtractor, detect_dataset, - import_dm_annotations) +from cvat.apps.dataset_manager.bindings import ( + GetCVATDataExtractor, + detect_dataset, + import_dm_annotations, +) from cvat.apps.dataset_manager.util import make_zip_archive -from .transformations import MaskToPolygonTransformation, RotatedBoxesToPolygons from .registry import dm_env, exporter, importer +from .transformations import MaskToPolygonTransformation, RotatedBoxesToPolygons from .utils import make_colormap + @exporter(name='Segmentation mask', ext='ZIP', version='1.1') def _export(dst_file, temp_dir, instance_data, save_images=False): with GetCVATDataExtractor(instance_data, include_images=save_images) as extractor: diff --git a/cvat/apps/dataset_manager/formats/mots.py b/cvat/apps/dataset_manager/formats/mots.py index 9ed156e6cd4e..736ccb1ce0f8 100644 --- a/cvat/apps/dataset_manager/formats/mots.py +++ b/cvat/apps/dataset_manager/formats/mots.py @@ -8,12 +8,16 @@ from datumaro.components.extractor import ItemTransform from pyunpack import Archive -from cvat.apps.dataset_manager.bindings import (GetCVATDataExtractor, detect_dataset, - find_dataset_root, match_dm_item) +from cvat.apps.dataset_manager.bindings import ( + GetCVATDataExtractor, + detect_dataset, + find_dataset_root, + match_dm_item, +) from cvat.apps.dataset_manager.util import make_zip_archive -from .transformations import MaskToPolygonTransformation, RotatedBoxesToPolygons from .registry import dm_env, exporter, importer +from .transformations import MaskToPolygonTransformation, RotatedBoxesToPolygons class KeepTracks(ItemTransform): diff --git a/cvat/apps/dataset_manager/formats/openimages.py b/cvat/apps/dataset_manager/formats/openimages.py index 51fcee29a2fb..c383a64e188a 100644 --- a/cvat/apps/dataset_manager/formats/openimages.py +++ b/cvat/apps/dataset_manager/formats/openimages.py @@ -11,12 +11,17 @@ from datumaro.util.image import DEFAULT_IMAGE_META_FILE_NAME from pyunpack import Archive -from cvat.apps.dataset_manager.bindings import (GetCVATDataExtractor, detect_dataset, - find_dataset_root, import_dm_annotations, match_dm_item) +from cvat.apps.dataset_manager.bindings import ( + GetCVATDataExtractor, + detect_dataset, + find_dataset_root, + import_dm_annotations, + match_dm_item, +) from cvat.apps.dataset_manager.util import make_zip_archive -from .transformations import MaskToPolygonTransformation, RotatedBoxesToPolygons from .registry import dm_env, exporter, importer +from .transformations import MaskToPolygonTransformation, RotatedBoxesToPolygons def find_item_ids(path): diff --git a/cvat/apps/dataset_manager/formats/pascal_voc.py b/cvat/apps/dataset_manager/formats/pascal_voc.py index a0d84b745d73..3b55928e1f90 100644 --- a/cvat/apps/dataset_manager/formats/pascal_voc.py +++ b/cvat/apps/dataset_manager/formats/pascal_voc.py @@ -11,7 +11,11 @@ from datumaro.components.dataset import Dataset from pyunpack import Archive -from cvat.apps.dataset_manager.bindings import (GetCVATDataExtractor, detect_dataset, import_dm_annotations) +from cvat.apps.dataset_manager.bindings import ( + GetCVATDataExtractor, + detect_dataset, + import_dm_annotations, +) from cvat.apps.dataset_manager.formats.transformations import MaskToPolygonTransformation from cvat.apps.dataset_manager.util import make_zip_archive diff --git a/cvat/apps/dataset_manager/formats/pointcloud.py b/cvat/apps/dataset_manager/formats/pointcloud.py index 6ddfbb495427..8743c6eb8f3c 100644 --- a/cvat/apps/dataset_manager/formats/pointcloud.py +++ b/cvat/apps/dataset_manager/formats/pointcloud.py @@ -7,8 +7,11 @@ from datumaro.components.dataset import Dataset -from cvat.apps.dataset_manager.bindings import (GetCVATDataExtractor, detect_dataset, - import_dm_annotations) +from cvat.apps.dataset_manager.bindings import ( + GetCVATDataExtractor, + detect_dataset, + import_dm_annotations, +) from cvat.apps.dataset_manager.util import make_zip_archive from cvat.apps.engine.models import DimensionType diff --git a/cvat/apps/dataset_manager/formats/transformations.py b/cvat/apps/dataset_manager/formats/transformations.py index 99d754252378..a8d361a00168 100644 --- a/cvat/apps/dataset_manager/formats/transformations.py +++ b/cvat/apps/dataset_manager/formats/transformations.py @@ -4,12 +4,12 @@ # SPDX-License-Identifier: MIT import math -import cv2 -import numpy as np from itertools import chain -from pycocotools import mask as mask_utils +import cv2 import datumaro as dm +import numpy as np +from pycocotools import mask as mask_utils class RotatedBoxesToPolygons(dm.ItemTransform): diff --git a/cvat/apps/dataset_manager/formats/utils.py b/cvat/apps/dataset_manager/formats/utils.py index 7811fbbfc902..f565c0aed687 100644 --- a/cvat/apps/dataset_manager/formats/utils.py +++ b/cvat/apps/dataset_manager/formats/utils.py @@ -2,13 +2,14 @@ # # SPDX-License-Identifier: MIT -import os.path as osp -from hashlib import blake2s import itertools import operator +import os.path as osp +from hashlib import blake2s from datumaro.util.os_util import make_file_name + def get_color_from_index(index): def get_bit(number, index): return (number >> index) & 1 diff --git a/cvat/apps/dataset_manager/formats/velodynepoint.py b/cvat/apps/dataset_manager/formats/velodynepoint.py index 9912d0b1d67b..d6051bf6fce8 100644 --- a/cvat/apps/dataset_manager/formats/velodynepoint.py +++ b/cvat/apps/dataset_manager/formats/velodynepoint.py @@ -8,14 +8,16 @@ from datumaro.components.dataset import Dataset from datumaro.components.extractor import ItemTransform -from cvat.apps.dataset_manager.bindings import GetCVATDataExtractor, detect_dataset, \ - import_dm_annotations -from .registry import dm_env - +from cvat.apps.dataset_manager.bindings import ( + GetCVATDataExtractor, + detect_dataset, + import_dm_annotations, +) from cvat.apps.dataset_manager.util import make_zip_archive from cvat.apps.engine.models import DimensionType -from .registry import exporter, importer +from .registry import dm_env, exporter, importer + class RemoveTrackingInformation(ItemTransform): def transform_item(self, item): diff --git a/cvat/apps/dataset_manager/formats/vggface2.py b/cvat/apps/dataset_manager/formats/vggface2.py index 642171f0f8d9..aa172f947db3 100644 --- a/cvat/apps/dataset_manager/formats/vggface2.py +++ b/cvat/apps/dataset_manager/formats/vggface2.py @@ -7,8 +7,12 @@ from datumaro.components.dataset import Dataset -from cvat.apps.dataset_manager.bindings import GetCVATDataExtractor, TaskData, detect_dataset, \ - import_dm_annotations +from cvat.apps.dataset_manager.bindings import ( + GetCVATDataExtractor, + TaskData, + detect_dataset, + import_dm_annotations, +) from cvat.apps.dataset_manager.util import make_zip_archive from .registry import dm_env, exporter, importer diff --git a/cvat/apps/dataset_manager/formats/widerface.py b/cvat/apps/dataset_manager/formats/widerface.py index 12a9bf0d21e5..99480bf1f8f5 100644 --- a/cvat/apps/dataset_manager/formats/widerface.py +++ b/cvat/apps/dataset_manager/formats/widerface.py @@ -7,8 +7,11 @@ from datumaro.components.dataset import Dataset -from cvat.apps.dataset_manager.bindings import GetCVATDataExtractor, detect_dataset, \ - import_dm_annotations +from cvat.apps.dataset_manager.bindings import ( + GetCVATDataExtractor, + detect_dataset, + import_dm_annotations, +) from cvat.apps.dataset_manager.util import make_zip_archive from .registry import dm_env, exporter, importer diff --git a/cvat/apps/dataset_manager/formats/yolo.py b/cvat/apps/dataset_manager/formats/yolo.py index 1a138557c862..fac91eae4d35 100644 --- a/cvat/apps/dataset_manager/formats/yolo.py +++ b/cvat/apps/dataset_manager/formats/yolo.py @@ -5,19 +5,19 @@ import os.path as osp from glob import glob +from datumaro.components.annotation import AnnotationType +from datumaro.components.extractor import DatasetItem +from datumaro.components.project import Dataset from pyunpack import Archive from cvat.apps.dataset_manager.bindings import ( GetCVATDataExtractor, detect_dataset, + find_dataset_root, import_dm_annotations, match_dm_item, - find_dataset_root, ) from cvat.apps.dataset_manager.util import make_zip_archive -from datumaro.components.annotation import AnnotationType -from datumaro.components.extractor import DatasetItem -from datumaro.components.project import Dataset from .registry import dm_env, exporter, importer diff --git a/cvat/apps/dataset_manager/project.py b/cvat/apps/dataset_manager/project.py index 93ac651cf477..ad51370b04e1 100644 --- a/cvat/apps/dataset_manager/project.py +++ b/cvat/apps/dataset_manager/project.py @@ -6,22 +6,22 @@ import os from collections.abc import Mapping from tempfile import TemporaryDirectory -import rq from typing import Any, Callable -from datumaro.components.errors import DatasetError, DatasetImportError, DatasetNotFoundError -from django.db import transaction +import rq +from datumaro.components.errors import DatasetError, DatasetImportError, DatasetNotFoundError from django.conf import settings +from django.db import transaction +from cvat.apps.dataset_manager.task import TaskAnnotation from cvat.apps.engine import models from cvat.apps.engine.log import DatasetLogManager +from cvat.apps.engine.rq_job_handler import RQJobMetaField from cvat.apps.engine.serializers import DataSerializer, TaskWriteSerializer from cvat.apps.engine.task import _create_thread as create_task -from cvat.apps.engine.rq_job_handler import RQJobMetaField -from cvat.apps.dataset_manager.task import TaskAnnotation from .annotation import AnnotationIR -from .bindings import CvatDatasetNotFoundError, ProjectData, load_dataset_data, CvatImportError +from .bindings import CvatDatasetNotFoundError, CvatImportError, ProjectData, load_dataset_data from .formats.registry import make_exporter, make_importer dlogger = DatasetLogManager() diff --git a/cvat/apps/dataset_manager/task.py b/cvat/apps/dataset_manager/task.py index 83886d7e9cf1..74f035d40787 100644 --- a/cvat/apps/dataset_manager/task.py +++ b/cvat/apps/dataset_manager/task.py @@ -10,27 +10,34 @@ from enum import Enum from tempfile import TemporaryDirectory from typing import Optional, Union -from datumaro.components.errors import DatasetError, DatasetImportError, DatasetNotFoundError +from datumaro.components.errors import DatasetError, DatasetImportError, DatasetNotFoundError +from django.conf import settings from django.db import transaction from django.db.models.query import Prefetch, QuerySet -from django.conf import settings from rest_framework.exceptions import ValidationError +from cvat.apps.dataset_manager.annotation import AnnotationIR, AnnotationManager +from cvat.apps.dataset_manager.bindings import ( + CvatDatasetNotFoundError, + CvatImportError, + JobData, + TaskData, +) +from cvat.apps.dataset_manager.formats.registry import make_exporter, make_importer +from cvat.apps.dataset_manager.util import ( + add_prefetch_fields, + bulk_create, + faster_deepcopy, + get_cached, +) from cvat.apps.engine import models, serializers -from cvat.apps.engine.plugins import plugin_decorator from cvat.apps.engine.log import DatasetLogManager +from cvat.apps.engine.plugins import plugin_decorator from cvat.apps.engine.utils import take_by from cvat.apps.events.handlers import handle_annotations_change from cvat.apps.profiler import silk_profile -from cvat.apps.dataset_manager.annotation import AnnotationIR, AnnotationManager -from cvat.apps.dataset_manager.bindings import TaskData, JobData, CvatImportError, CvatDatasetNotFoundError -from cvat.apps.dataset_manager.formats.registry import make_exporter, make_importer -from cvat.apps.dataset_manager.util import ( - add_prefetch_fields, bulk_create, get_cached, faster_deepcopy -) - dlogger = DatasetLogManager() class dotdict(OrderedDict): diff --git a/cvat/apps/dataset_manager/tests/test_formats.py b/cvat/apps/dataset_manager/tests/test_formats.py index e6ba111f29f9..9dced8542c21 100644 --- a/cvat/apps/dataset_manager/tests/test_formats.py +++ b/cvat/apps/dataset_manager/tests/test_formats.py @@ -4,28 +4,33 @@ # # SPDX-License-Identifier: MIT -import numpy as np import os.path as osp import tempfile import zipfile from io import BytesIO import datumaro -from datumaro.components.dataset import Dataset, DatasetItem +import numpy as np from datumaro.components.annotation import Mask +from datumaro.components.dataset import Dataset, DatasetItem from django.contrib.auth.models import Group, User - from rest_framework import status import cvat.apps.dataset_manager as dm from cvat.apps.dataset_manager.annotation import AnnotationIR -from cvat.apps.dataset_manager.bindings import (CvatTaskOrJobDataExtractor, - TaskData, find_dataset_root) +from cvat.apps.dataset_manager.bindings import ( + CvatTaskOrJobDataExtractor, + TaskData, + find_dataset_root, +) from cvat.apps.dataset_manager.task import TaskAnnotation from cvat.apps.dataset_manager.util import make_zip_archive from cvat.apps.engine.models import Task from cvat.apps.engine.tests.utils import ( - get_paginated_collection, ForceLogin, generate_image_file, ApiTestBase + ApiTestBase, + ForceLogin, + generate_image_file, + get_paginated_collection, ) diff --git a/cvat/apps/dataset_manager/tests/test_rest_api_formats.py b/cvat/apps/dataset_manager/tests/test_rest_api_formats.py index f3640b835bcb..7be4f0e753a4 100644 --- a/cvat/apps/dataset_manager/tests/test_rest_api_formats.py +++ b/cvat/apps/dataset_manager/tests/test_rest_api_formats.py @@ -6,11 +6,9 @@ import copy import itertools import json -import os.path as osp -import os import multiprocessing -import av -import numpy as np +import os +import os.path as osp import random import shutil import xml.etree.ElementTree as ET @@ -22,8 +20,11 @@ from tempfile import TemporaryDirectory from time import sleep from typing import Any, Callable, ClassVar, Optional, overload -from unittest.mock import MagicMock, patch, DEFAULT as MOCK_DEFAULT +from unittest.mock import DEFAULT as MOCK_DEFAULT +from unittest.mock import MagicMock, patch +import av +import numpy as np from attr import define, field from datumaro.components.dataset import Dataset from datumaro.components.operations import ExactComparator @@ -38,7 +39,7 @@ from cvat.apps.dataset_manager.util import get_export_cache_lock from cvat.apps.dataset_manager.views import clear_export_cache, export, parse_export_file_path from cvat.apps.engine.models import Task -from cvat.apps.engine.tests.utils import get_paginated_collection, ApiTestBase, ForceLogin +from cvat.apps.engine.tests.utils import ApiTestBase, ForceLogin, get_paginated_collection projects_path = osp.join(osp.dirname(__file__), 'assets', 'projects.json') with open(projects_path) as file: @@ -1451,8 +1452,8 @@ def _export(*_, task_id: int): import sys from os import replace as original_replace from os.path import exists as original_exists - from cvat.apps.dataset_manager.task import export_task as original_export_task + from cvat.apps.dataset_manager.task import export_task as original_export_task from cvat.apps.dataset_manager.views import log_exception as original_log_exception def patched_log_exception(logger=None, exc_info=True): diff --git a/cvat/apps/dataset_manager/views.py b/cvat/apps/dataset_manager/views.py index 52bc9cd15f7a..4dcd8304e43d 100644 --- a/cvat/apps/dataset_manager/views.py +++ b/cvat/apps/dataset_manager/views.py @@ -8,10 +8,10 @@ import os.path as osp import tempfile from datetime import timedelta +from os.path import exists as osp_exists import django_rq import rq -from os.path import exists as osp_exists from django.conf import settings from django.utils import timezone from rq_scheduler import Scheduler @@ -20,18 +20,20 @@ import cvat.apps.dataset_manager.task as task from cvat.apps.engine.log import ServerLogManager from cvat.apps.engine.models import Job, Project, Task -from cvat.apps.engine.utils import get_rq_lock_by_user from cvat.apps.engine.rq_job_handler import RQMeta +from cvat.apps.engine.utils import get_rq_lock_by_user from .formats.registry import EXPORT_FORMATS, IMPORT_FORMATS +from .util import EXPORT_CACHE_DIR_NAME # pylint: disable=unused-import from .util import ( LockNotAvailableError, - current_function_name, get_export_cache_lock, - get_export_cache_dir, make_export_filename, - parse_export_file_path, extend_export_file_lifetime + current_function_name, + extend_export_file_lifetime, + get_export_cache_dir, + get_export_cache_lock, + make_export_filename, + parse_export_file_path, ) -from .util import EXPORT_CACHE_DIR_NAME # pylint: disable=unused-import - slogger = ServerLogManager(__name__) diff --git a/cvat/apps/dataset_repo/migrations/0001_initial.py b/cvat/apps/dataset_repo/migrations/0001_initial.py index 2ecf9c17c9b9..fb4cf35a9672 100644 --- a/cvat/apps/dataset_repo/migrations/0001_initial.py +++ b/cvat/apps/dataset_repo/migrations/0001_initial.py @@ -1,7 +1,7 @@ # Generated by Django 2.1.3 on 2018-12-05 13:24 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/cvat/apps/dataset_repo/migrations/0004_rename.py b/cvat/apps/dataset_repo/migrations/0004_rename.py index 9629165722d1..05072160398e 100644 --- a/cvat/apps/dataset_repo/migrations/0004_rename.py +++ b/cvat/apps/dataset_repo/migrations/0004_rename.py @@ -1,5 +1,6 @@ from django.db import migrations + def update_contenttypes_table(apps, schema_editor): content_type_model = apps.get_model('contenttypes', 'ContentType') content_type_model.objects.filter(app_label='git').update(app_label='dataset_repo') diff --git a/cvat/apps/engine/__init__.py b/cvat/apps/engine/__init__.py index 325276288f0f..f6b1f2bb9381 100644 --- a/cvat/apps/engine/__init__.py +++ b/cvat/apps/engine/__init__.py @@ -4,4 +4,4 @@ # SPDX-License-Identifier: MIT -from .schema import * # force import of declared symbols +from .schema import * # force import of declared symbols diff --git a/cvat/apps/engine/admin.py b/cvat/apps/engine/admin.py index 05e4b40a0f9b..712e67fa5582 100644 --- a/cvat/apps/engine/admin.py +++ b/cvat/apps/engine/admin.py @@ -4,8 +4,21 @@ # SPDX-License-Identifier: MIT from django.contrib import admin -from .models import Task, Segment, Job, Label, AttributeSpec, Project, \ - CloudStorage, Storage, Data, AnnotationGuide, Asset + +from .models import ( + AnnotationGuide, + Asset, + AttributeSpec, + CloudStorage, + Data, + Job, + Label, + Project, + Segment, + Storage, + Task, +) + class JobInline(admin.TabularInline): model = Job diff --git a/cvat/apps/engine/apps.py b/cvat/apps/engine/apps.py index bcad84510f5d..1cea639842c8 100644 --- a/cvat/apps/engine/apps.py +++ b/cvat/apps/engine/apps.py @@ -20,6 +20,7 @@ def ready(self): # Required to define signals in application import cvat.apps.engine.signals + # Required in order to silent "unused-import" in pyflake assert cvat.apps.engine.signals diff --git a/cvat/apps/engine/backup.py b/cvat/apps/engine/backup.py index 3c8ba5678c24..f3790427f5ba 100644 --- a/cvat/apps/engine/backup.py +++ b/cvat/apps/engine/backup.py @@ -21,37 +21,57 @@ from django.conf import settings from django.db import transaction from django.utils import timezone - from rest_framework import serializers, status +from rest_framework.exceptions import ValidationError from rest_framework.parsers import JSONParser from rest_framework.renderers import JSONRenderer from rest_framework.response import Response -from rest_framework.exceptions import ValidationError import cvat.apps.dataset_manager as dm +from cvat.apps.dataset_manager.bindings import CvatImportError +from cvat.apps.dataset_manager.views import get_export_cache_dir, log_exception from cvat.apps.engine import models +from cvat.apps.engine.cloud_provider import import_resource_from_cloud_storage +from cvat.apps.engine.location import StorageType, get_location_configuration from cvat.apps.engine.log import ServerLogManager -from cvat.apps.engine.serializers import (AttributeSerializer, DataSerializer, JobWriteSerializer, - LabelSerializer, AnnotationGuideWriteSerializer, AssetWriteSerializer, - LabeledDataSerializer, SegmentSerializer, SimpleJobSerializer, TaskReadSerializer, - ProjectReadSerializer, ProjectFileSerializer, TaskFileSerializer, RqIdSerializer, - ValidationParamsSerializer) -from cvat.apps.engine.utils import ( - av_scan_paths, process_failed_job, - get_rq_job_meta, import_resource_with_clean_up_after, - define_dependent_job, get_rq_lock_by_user, +from cvat.apps.engine.models import ( + DataChoice, + Location, + Project, + RequestAction, + RequestSubresource, + RequestTarget, + StorageChoice, + StorageMethodChoice, ) +from cvat.apps.engine.permissions import get_cloud_storage_for_import_or_export from cvat.apps.engine.rq_job_handler import RQId, RQJobMetaField -from cvat.apps.engine.models import ( - StorageChoice, StorageMethodChoice, DataChoice, Project, Location, - RequestAction, RequestTarget, RequestSubresource, +from cvat.apps.engine.serializers import ( + AnnotationGuideWriteSerializer, + AssetWriteSerializer, + AttributeSerializer, + DataSerializer, + JobWriteSerializer, + LabeledDataSerializer, + LabelSerializer, + ProjectFileSerializer, + ProjectReadSerializer, + RqIdSerializer, + SegmentSerializer, + SimpleJobSerializer, + TaskFileSerializer, + TaskReadSerializer, + ValidationParamsSerializer, ) from cvat.apps.engine.task import JobFileMapping, _create_thread -from cvat.apps.engine.cloud_provider import import_resource_from_cloud_storage -from cvat.apps.engine.location import StorageType, get_location_configuration -from cvat.apps.engine.permissions import get_cloud_storage_for_import_or_export -from cvat.apps.dataset_manager.views import get_export_cache_dir, log_exception -from cvat.apps.dataset_manager.bindings import CvatImportError +from cvat.apps.engine.utils import ( + av_scan_paths, + define_dependent_job, + get_rq_job_meta, + get_rq_lock_by_user, + import_resource_with_clean_up_after, + process_failed_job, +) slogger = ServerLogManager(__name__) diff --git a/cvat/apps/engine/cloud_provider.py b/cvat/apps/engine/cloud_provider.py index b810304d73f9..06b2496ce16b 100644 --- a/cvat/apps/engine/cloud_provider.py +++ b/cvat/apps/engine/cloud_provider.py @@ -5,14 +5,14 @@ import functools import json -import os import math +import os from abc import ABC, abstractmethod from collections.abc import Iterator -from concurrent.futures import ThreadPoolExecutor, wait, FIRST_EXCEPTION +from concurrent.futures import FIRST_EXCEPTION, ThreadPoolExecutor, wait from enum import Enum from io import BytesIO -from typing import Optional, Any, Callable, TypeVar +from typing import Any, Callable, Optional, TypeVar import boto3 from azure.core.exceptions import HttpResponseError, ResourceExistsError @@ -27,14 +27,14 @@ from google.cloud.exceptions import Forbidden as GoogleCloudForbidden from google.cloud.exceptions import NotFound as GoogleCloudNotFound from PIL import Image, ImageFile -from rest_framework.exceptions import (NotFound, PermissionDenied, - ValidationError) +from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError from cvat.apps.engine.log import ServerLogManager from cvat.apps.engine.models import CloudProviderChoice, CredentialsTypeChoice from cvat.apps.engine.utils import get_cpu_number, take_by from cvat.utils.http import PROXIES_FOR_UNTRUSTED_URLS + class NamedBytesIO(BytesIO): @property def filename(self) -> Optional[str]: diff --git a/cvat/apps/engine/filters.py b/cvat/apps/engine/filters.py index 32355629d06d..6a80e94ad6cc 100644 --- a/cvat/apps/engine/filters.py +++ b/cvat/apps/engine/filters.py @@ -3,25 +3,25 @@ # # SPDX-License-Identifier: MIT -from collections.abc import Iterator, Iterable +import json +import operator +from collections.abc import Iterable, Iterator from functools import reduce +from textwrap import dedent from typing import Any, Optional -import operator -import json +from django.db.models import Q +from django.db.models.query import QuerySet +from django.utils.encoding import force_str +from django.utils.translation import gettext_lazy as _ from django_filters import FilterSet from django_filters import filters as djf from django_filters.filterset import BaseFilterSet from django_filters.rest_framework import DjangoFilterBackend -from django.db.models import Q -from django.db.models.query import QuerySet -from django.utils.translation import gettext_lazy as _ -from django.utils.encoding import force_str -from rest_framework.request import Request from rest_framework import filters from rest_framework.compat import coreapi, coreschema from rest_framework.exceptions import ValidationError -from textwrap import dedent +from rest_framework.request import Request DEFAULT_FILTER_FIELDS_ATTR = 'filter_fields' DEFAULT_LOOKUP_MAP_ATTR = 'lookup_fields' diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index 6b756543c7f3..1a5fd1f40ebd 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -15,15 +15,7 @@ from dataclasses import dataclass from enum import Enum, auto from io import BytesIO -from typing import ( - Any, - Callable, - Generic, - Optional, - TypeVar, - Union, - overload, -) +from typing import Any, Callable, Generic, Optional, TypeVar, Union, overload import av import cv2 diff --git a/cvat/apps/engine/handlers.py b/cvat/apps/engine/handlers.py index d686bbf0ba5c..0a831a44827b 100644 --- a/cvat/apps/engine/handlers.py +++ b/cvat/apps/engine/handlers.py @@ -4,7 +4,9 @@ from pathlib import Path from time import time + from django.conf import settings + from cvat.apps.engine.log import ServerLogManager slogger = ServerLogManager(__name__) diff --git a/cvat/apps/engine/location.py b/cvat/apps/engine/location.py index c9e216e24627..deea541f09d3 100644 --- a/cvat/apps/engine/location.py +++ b/cvat/apps/engine/location.py @@ -3,9 +3,10 @@ # SPDX-License-Identifier: MIT from enum import Enum -from typing import Any, Union, Optional +from typing import Any, Optional, Union + +from cvat.apps.engine.models import Job, Location, Project, Task -from cvat.apps.engine.models import Location, Project, Task, Job class StorageType(str, Enum): TARGET = 'target_storage' diff --git a/cvat/apps/engine/log.py b/cvat/apps/engine/log.py index 6f1740e74fd4..3cc2cecff37b 100644 --- a/cvat/apps/engine/log.py +++ b/cvat/apps/engine/log.py @@ -4,12 +4,15 @@ # SPDX-License-Identifier: MIT import logging -import sys import os.path as osp +import sys from contextlib import contextmanager -from cvat.apps.engine.utils import directory_tree + from django.conf import settings +from cvat.apps.engine.utils import directory_tree + + class _LoggerAdapter(logging.LoggerAdapter): def process(self, msg: str, kwargs): if msg_prefix := self.extra.get("msg_prefix"): diff --git a/cvat/apps/engine/management/commands/syncperiodicjobs.py b/cvat/apps/engine/management/commands/syncperiodicjobs.py index 097f468b337f..d78d3f247179 100644 --- a/cvat/apps/engine/management/commands/syncperiodicjobs.py +++ b/cvat/apps/engine/management/commands/syncperiodicjobs.py @@ -5,10 +5,10 @@ from argparse import ArgumentParser from collections import defaultdict -from django.core.management.base import BaseCommand +import django_rq from django.conf import settings +from django.core.management.base import BaseCommand -import django_rq class Command(BaseCommand): help = "Synchronize periodic jobs in Redis with the project configuration" diff --git a/cvat/apps/engine/media_extractors.py b/cvat/apps/engine/media_extractors.py index ae1c7b9f7da8..09c2ce2876de 100644 --- a/cvat/apps/engine/media_extractors.py +++ b/cvat/apps/engine/media_extractors.py @@ -5,20 +5,21 @@ from __future__ import annotations +import io +import itertools import os +import shutil +import struct import sysconfig import tempfile -import shutil import zipfile -import io -import itertools -import struct from abc import ABC, abstractmethod from bisect import bisect from collections.abc import Generator, Iterable, Iterator, Sequence from contextlib import AbstractContextManager, ExitStack, closing, contextmanager from dataclasses import dataclass from enum import IntEnum +from random import shuffle from typing import Any, Callable, Optional, Protocol, TypeVar, Union import av @@ -27,19 +28,19 @@ import av.video.stream import numpy as np from natsort import os_sorted -from pyunpack import Archive from PIL import Image, ImageFile, ImageOps -from random import shuffle -from cvat.apps.engine.utils import rotate_image -from cvat.apps.engine.models import DimensionType, SortingMethod +from pyunpack import Archive from rest_framework.exceptions import ValidationError +from cvat.apps.engine.models import DimensionType, SortingMethod +from cvat.apps.engine.utils import rotate_image + # fixes: "OSError:broken data stream" when executing line 72 while loading images downloaded from the web # see: https://stackoverflow.com/questions/42462431/oserror-broken-data-stream-when-reading-image-file ImageFile.LOAD_TRUNCATED_IMAGES = True from cvat.apps.engine.mime_types import mimetypes -from utils.dataset_manifest import VideoManifestManager, ImageManifestManager +from utils.dataset_manifest import ImageManifestManager, VideoManifestManager ORIENTATION_EXIF_TAG = 274 diff --git a/cvat/apps/engine/middleware.py b/cvat/apps/engine/middleware.py index f2b990a14b50..2e8f116f4ecd 100644 --- a/cvat/apps/engine/middleware.py +++ b/cvat/apps/engine/middleware.py @@ -4,6 +4,7 @@ from uuid import uuid4 + class RequestTrackingMiddleware: def __init__(self, get_response): self.get_response = get_response diff --git a/cvat/apps/engine/migrations/0001_release_v0_1_0.py b/cvat/apps/engine/migrations/0001_release_v0_1_0.py index 64d030cc81c6..59edc03104f4 100644 --- a/cvat/apps/engine/migrations/0001_release_v0_1_0.py +++ b/cvat/apps/engine/migrations/0001_release_v0_1_0.py @@ -5,9 +5,9 @@ # Generated by Django 2.0.3 on 2018-05-23 11:51 +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): diff --git a/cvat/apps/engine/migrations/0002_labeledpoints_labeledpointsattributeval_labeledpolygon_labeledpolygonattributeval_labeledpolyline_la.py b/cvat/apps/engine/migrations/0002_labeledpoints_labeledpointsattributeval_labeledpolygon_labeledpolygonattributeval_labeledpolyline_la.py index 0e7820999c38..fa3e6fe79b94 100644 --- a/cvat/apps/engine/migrations/0002_labeledpoints_labeledpointsattributeval_labeledpolygon_labeledpolygonattributeval_labeledpolyline_la.py +++ b/cvat/apps/engine/migrations/0002_labeledpoints_labeledpointsattributeval_labeledpolygon_labeledpolygonattributeval_labeledpolyline_la.py @@ -5,8 +5,8 @@ # Generated by Django 2.0.3 on 2018-05-30 09:53 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/cvat/apps/engine/migrations/0008_auto_20180917_1424.py b/cvat/apps/engine/migrations/0008_auto_20180917_1424.py index cf6b45500d90..a32051d585e4 100644 --- a/cvat/apps/engine/migrations/0008_auto_20180917_1424.py +++ b/cvat/apps/engine/migrations/0008_auto_20180917_1424.py @@ -1,8 +1,8 @@ # Generated by Django 2.0.3 on 2018-09-17 11:24 +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): diff --git a/cvat/apps/engine/migrations/0011_add_task_source_and_safecharfield.py b/cvat/apps/engine/migrations/0011_add_task_source_and_safecharfield.py index bb96c1b588dd..4b168322d486 100644 --- a/cvat/apps/engine/migrations/0011_add_task_source_and_safecharfield.py +++ b/cvat/apps/engine/migrations/0011_add_task_source_and_safecharfield.py @@ -1,8 +1,9 @@ # Generated by Django 2.0.9 on 2018-10-24 10:50 -import cvat.apps.engine.models from django.db import migrations +import cvat.apps.engine.models + class Migration(migrations.Migration): diff --git a/cvat/apps/engine/migrations/0013_auth_no_default_permissions.py b/cvat/apps/engine/migrations/0013_auth_no_default_permissions.py index bc735269eed6..2dabe07fe9a0 100644 --- a/cvat/apps/engine/migrations/0013_auth_no_default_permissions.py +++ b/cvat/apps/engine/migrations/0013_auth_no_default_permissions.py @@ -1,8 +1,8 @@ # Generated by Django 2.0.9 on 2018-11-07 12:25 +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): diff --git a/cvat/apps/engine/migrations/0015_db_redesign_20190217.py b/cvat/apps/engine/migrations/0015_db_redesign_20190217.py index db9589d8b807..accac35b8187 100644 --- a/cvat/apps/engine/migrations/0015_db_redesign_20190217.py +++ b/cvat/apps/engine/migrations/0015_db_redesign_20190217.py @@ -1,11 +1,13 @@ # Generated by Django 2.1.5 on 2019-02-17 19:32 -from django.conf import settings -from django.db import migrations, models import django.db.migrations.operations.special import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + import cvat.apps.engine.models + def set_segment_size(apps, schema_editor): Task = apps.get_model('engine', 'Task') for task in Task.objects.all(): diff --git a/cvat/apps/engine/migrations/0016_attribute_spec_20190217.py b/cvat/apps/engine/migrations/0016_attribute_spec_20190217.py index 27d273af2790..ac060ad69326 100644 --- a/cvat/apps/engine/migrations/0016_attribute_spec_20190217.py +++ b/cvat/apps/engine/migrations/0016_attribute_spec_20190217.py @@ -1,12 +1,15 @@ +import csv import os import re -import csv from io import StringIO -from PIL import Image -from django.db import migrations + from django.conf import settings +from django.db import migrations +from PIL import Image + from cvat.apps.engine.media_extractors import get_mime + def parse_attribute(value): match = re.match(r'^([~@])(\w+)=(\w+):(.+)?$', value) if match: diff --git a/cvat/apps/engine/migrations/0017_db_redesign_20190221.py b/cvat/apps/engine/migrations/0017_db_redesign_20190221.py index 22b7e5d28881..d30d5fa0a73a 100644 --- a/cvat/apps/engine/migrations/0017_db_redesign_20190221.py +++ b/cvat/apps/engine/migrations/0017_db_redesign_20190221.py @@ -1,11 +1,13 @@ # Generated by Django 2.1.5 on 2019-02-21 12:25 -import cvat.apps.engine.models -from django.db import migrations, models import django.db.models.deletion from django.conf import settings +from django.db import migrations, models + +import cvat.apps.engine.models from cvat.apps.dataset_manager.task import merge_table_rows as _merge_table_rows + # some modified functions to transfer annotation def _bulk_create(db_model, db_alias, objects, flt_param): if objects: diff --git a/cvat/apps/engine/migrations/0018_jobcommit.py b/cvat/apps/engine/migrations/0018_jobcommit.py index c526cb896435..b25187c50f60 100644 --- a/cvat/apps/engine/migrations/0018_jobcommit.py +++ b/cvat/apps/engine/migrations/0018_jobcommit.py @@ -1,8 +1,8 @@ # Generated by Django 2.1.7 on 2019-04-17 09:25 +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): diff --git a/cvat/apps/engine/migrations/0020_remove_task_flipped.py b/cvat/apps/engine/migrations/0020_remove_task_flipped.py index 7ca57e880417..7744def2b302 100644 --- a/cvat/apps/engine/migrations/0020_remove_task_flipped.py +++ b/cvat/apps/engine/migrations/0020_remove_task_flipped.py @@ -1,14 +1,15 @@ # Generated by Django 2.1.7 on 2019-06-18 11:08 -from django.db import migrations +import os +from ast import literal_eval + from django.conf import settings +from django.db import migrations +from PIL import Image -from cvat.apps.engine.models import Job, ShapeType from cvat.apps.engine.media_extractors import get_mime +from cvat.apps.engine.models import Job, ShapeType -from PIL import Image -from ast import literal_eval -import os def make_image_meta_cache(db_task): with open(db_task.get_image_meta_cache_path(), 'w') as meta_file: diff --git a/cvat/apps/engine/migrations/0022_auto_20191004_0817.py b/cvat/apps/engine/migrations/0022_auto_20191004_0817.py index b48a24f583db..6fd0ca45d8c3 100644 --- a/cvat/apps/engine/migrations/0022_auto_20191004_0817.py +++ b/cvat/apps/engine/migrations/0022_auto_20191004_0817.py @@ -1,9 +1,10 @@ # Generated by Django 2.2.3 on 2019-10-04 08:17 -import cvat.apps.engine.models +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion + +import cvat.apps.engine.models class Migration(migrations.Migration): diff --git a/cvat/apps/engine/migrations/0023_auto_20200113_1323.py b/cvat/apps/engine/migrations/0023_auto_20200113_1323.py index 4089eb1a1a66..33c586398323 100644 --- a/cvat/apps/engine/migrations/0023_auto_20200113_1323.py +++ b/cvat/apps/engine/migrations/0023_auto_20200113_1323.py @@ -1,8 +1,9 @@ # Generated by Django 2.2.8 on 2020-01-13 13:23 -import cvat.apps.engine.models from django.db import migrations +import cvat.apps.engine.models + class Migration(migrations.Migration): diff --git a/cvat/apps/engine/migrations/0024_auto_20191023_1025.py b/cvat/apps/engine/migrations/0024_auto_20191023_1025.py index 1946e08e47e2..945879ef7552 100644 --- a/cvat/apps/engine/migrations/0024_auto_20191023_1025.py +++ b/cvat/apps/engine/migrations/0024_auto_20191023_1025.py @@ -1,24 +1,32 @@ # Generated by Django 2.2.4 on 2019-10-23 10:25 +import glob +import itertools +import multiprocessing import os import re import shutil -import glob import sys -import traceback -import itertools -import multiprocessing import time +import traceback -from django.db import migrations, models import django.db.models.deletion from django.conf import settings +from django.db import migrations, models -from cvat.apps.engine.media_extractors import (VideoReader, ArchiveReader, ZipReader, - PdfReader , ImageListReader, Mpeg4ChunkWriter, - ZipChunkWriter, ZipCompressedChunkWriter, get_mime) -from cvat.apps.engine.models import DataChoice from cvat.apps.engine.log import get_migration_logger +from cvat.apps.engine.media_extractors import ( + ArchiveReader, + ImageListReader, + Mpeg4ChunkWriter, + PdfReader, + VideoReader, + ZipChunkWriter, + ZipCompressedChunkWriter, + ZipReader, + get_mime, +) +from cvat.apps.engine.models import DataChoice MIGRATION_THREAD_COUNT = 2 diff --git a/cvat/apps/engine/migrations/0028_labelcolor.py b/cvat/apps/engine/migrations/0028_labelcolor.py index af30fbabd8d2..eda6215ecdd6 100644 --- a/cvat/apps/engine/migrations/0028_labelcolor.py +++ b/cvat/apps/engine/migrations/0028_labelcolor.py @@ -1,7 +1,9 @@ # Generated by Django 2.2.13 on 2020-08-11 11:26 from django.db import migrations, models + from cvat.apps.dataset_manager.formats.utils import get_label_color + def alter_label_colors(apps, schema_editor): Label = apps.get_model('engine', 'Label') Task = apps.get_model('engine', 'Task') diff --git a/cvat/apps/engine/migrations/0029_data_storage_method.py b/cvat/apps/engine/migrations/0029_data_storage_method.py index 1c1aa814e4cd..e5ee36f33f06 100644 --- a/cvat/apps/engine/migrations/0029_data_storage_method.py +++ b/cvat/apps/engine/migrations/0029_data_storage_method.py @@ -1,12 +1,15 @@ # Generated by Django 2.2.13 on 2020-08-13 05:49 -from cvat.apps.engine.media_extractors import _is_archive, _is_zip -import cvat.apps.engine.models +import os + from django.conf import settings from django.db import migrations, models -import os from pyunpack import Archive +import cvat.apps.engine.models +from cvat.apps.engine.media_extractors import _is_archive, _is_zip + + def unzip(apps, schema_editor): Data = apps.get_model("engine", "Data") data_q_set = Data.objects.all() diff --git a/cvat/apps/engine/migrations/0033_projects_adjastment.py b/cvat/apps/engine/migrations/0033_projects_adjastment.py index e57bd0e6c568..8af73e6d1da5 100644 --- a/cvat/apps/engine/migrations/0033_projects_adjastment.py +++ b/cvat/apps/engine/migrations/0033_projects_adjastment.py @@ -1,7 +1,7 @@ # Generated by Django 3.1.1 on 2020-09-24 12:44 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/cvat/apps/engine/migrations/0034_auto_20201125_1426.py b/cvat/apps/engine/migrations/0034_auto_20201125_1426.py index 311b21655b9d..d02582342893 100644 --- a/cvat/apps/engine/migrations/0034_auto_20201125_1426.py +++ b/cvat/apps/engine/migrations/0034_auto_20201125_1426.py @@ -1,9 +1,11 @@ # Generated by Django 3.1.1 on 2020-11-25 14:26 -import cvat.apps.engine.models +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion + +import cvat.apps.engine.models + def create_profile(apps, schema_editor): User = apps.get_model('auth', 'User') diff --git a/cvat/apps/engine/migrations/0035_data_storage.py b/cvat/apps/engine/migrations/0035_data_storage.py index 5a8a9903784f..075d7ce38015 100644 --- a/cvat/apps/engine/migrations/0035_data_storage.py +++ b/cvat/apps/engine/migrations/0035_data_storage.py @@ -1,8 +1,9 @@ # Generated by Django 3.1.1 on 2020-12-02 06:47 -import cvat.apps.engine.models from django.db import migrations, models +import cvat.apps.engine.models + class Migration(migrations.Migration): diff --git a/cvat/apps/engine/migrations/0036_auto_20201216_0943.py b/cvat/apps/engine/migrations/0036_auto_20201216_0943.py index 6f2fde01250f..52cb5faca2a5 100644 --- a/cvat/apps/engine/migrations/0036_auto_20201216_0943.py +++ b/cvat/apps/engine/migrations/0036_auto_20201216_0943.py @@ -1,8 +1,9 @@ # Generated by Django 3.1.1 on 2020-12-16 09:43 -import cvat.apps.engine.models -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models + +import cvat.apps.engine.models class Migration(migrations.Migration): diff --git a/cvat/apps/engine/migrations/0038_manifest.py b/cvat/apps/engine/migrations/0038_manifest.py index 002a0326c2dc..33208ad4cf19 100644 --- a/cvat/apps/engine/migrations/0038_manifest.py +++ b/cvat/apps/engine/migrations/0038_manifest.py @@ -9,9 +9,8 @@ from django.db import migrations from cvat.apps.engine.log import get_logger -from cvat.apps.engine.models import (DimensionType, StorageChoice, - StorageMethodChoice) from cvat.apps.engine.media_extractors import get_mime +from cvat.apps.engine.models import DimensionType, StorageChoice, StorageMethodChoice from utils.dataset_manifest import ImageManifestManager, VideoManifestManager MIGRATION_NAME = os.path.splitext(os.path.basename(__file__))[0] diff --git a/cvat/apps/engine/migrations/0039_auto_training.py b/cvat/apps/engine/migrations/0039_auto_training.py index a9f22ea7a03a..4594942d801e 100644 --- a/cvat/apps/engine/migrations/0039_auto_training.py +++ b/cvat/apps/engine/migrations/0039_auto_training.py @@ -1,7 +1,7 @@ # Generated by Django 3.1.7 on 2021-04-02 13:17 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/cvat/apps/engine/migrations/0040_cloud_storage.py b/cvat/apps/engine/migrations/0040_cloud_storage.py index c73609fd9fef..f7ecac010d19 100644 --- a/cvat/apps/engine/migrations/0040_cloud_storage.py +++ b/cvat/apps/engine/migrations/0040_cloud_storage.py @@ -1,9 +1,10 @@ # Generated by Django 3.1.8 on 2021-05-07 06:42 -import cvat.apps.engine.models +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion + +import cvat.apps.engine.models class Migration(migrations.Migration): diff --git a/cvat/apps/engine/migrations/0042_auto_20210830_1056.py b/cvat/apps/engine/migrations/0042_auto_20210830_1056.py index 7b5a496af97c..69866f2c788a 100644 --- a/cvat/apps/engine/migrations/0042_auto_20210830_1056.py +++ b/cvat/apps/engine/migrations/0042_auto_20210830_1056.py @@ -1,7 +1,7 @@ # Generated by Django 3.1.13 on 2021-08-30 10:56 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/cvat/apps/engine/migrations/0046_data_sorting_method.py b/cvat/apps/engine/migrations/0046_data_sorting_method.py index f3880482fc33..cb58bce9ed69 100644 --- a/cvat/apps/engine/migrations/0046_data_sorting_method.py +++ b/cvat/apps/engine/migrations/0046_data_sorting_method.py @@ -1,8 +1,9 @@ # Generated by Django 3.1.13 on 2021-12-03 08:06 -import cvat.apps.engine.models from django.db import migrations, models +import cvat.apps.engine.models + class Migration(migrations.Migration): replaces = [('engine', '0045_data_sorting_method')] diff --git a/cvat/apps/engine/migrations/0047_auto_20211110_1938.py b/cvat/apps/engine/migrations/0047_auto_20211110_1938.py index 69434115f269..0113b1816c67 100644 --- a/cvat/apps/engine/migrations/0047_auto_20211110_1938.py +++ b/cvat/apps/engine/migrations/0047_auto_20211110_1938.py @@ -1,8 +1,9 @@ # Generated by Django 3.2.8 on 2021-11-10 19:38 -import cvat.apps.engine.models -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models + +import cvat.apps.engine.models class Migration(migrations.Migration): diff --git a/cvat/apps/engine/migrations/0048_auto_20211112_1918.py b/cvat/apps/engine/migrations/0048_auto_20211112_1918.py index e1c54ab1206b..6c2106624397 100644 --- a/cvat/apps/engine/migrations/0048_auto_20211112_1918.py +++ b/cvat/apps/engine/migrations/0048_auto_20211112_1918.py @@ -1,8 +1,8 @@ # Generated by Django 3.2.8 on 2021-11-12 19:18 +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): diff --git a/cvat/apps/engine/migrations/0053_data_deleted_frames.py b/cvat/apps/engine/migrations/0053_data_deleted_frames.py index 8bbf49792f49..e1421a0a2c1f 100644 --- a/cvat/apps/engine/migrations/0053_data_deleted_frames.py +++ b/cvat/apps/engine/migrations/0053_data_deleted_frames.py @@ -1,8 +1,9 @@ # Generated by Django 3.2.12 on 2022-05-20 09:21 -import cvat.apps.engine.models from django.db import migrations +import cvat.apps.engine.models + class Migration(migrations.Migration): diff --git a/cvat/apps/engine/migrations/0054_auto_20220610_1829.py b/cvat/apps/engine/migrations/0054_auto_20220610_1829.py index 1c7ae1a802ec..25ed5b9c0617 100644 --- a/cvat/apps/engine/migrations/0054_auto_20220610_1829.py +++ b/cvat/apps/engine/migrations/0054_auto_20220610_1829.py @@ -1,8 +1,9 @@ # Generated by Django 3.2.12 on 2022-06-10 18:29 -import cvat.apps.engine.models -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models + +import cvat.apps.engine.models class Migration(migrations.Migration): diff --git a/cvat/apps/engine/migrations/0055_jobs_directories.py b/cvat/apps/engine/migrations/0055_jobs_directories.py index ec97f2c8d3d5..89d7cd300b24 100644 --- a/cvat/apps/engine/migrations/0055_jobs_directories.py +++ b/cvat/apps/engine/migrations/0055_jobs_directories.py @@ -3,8 +3,9 @@ import os import shutil -from django.db import migrations from django.conf import settings +from django.db import migrations + from cvat.apps.engine.log import get_logger MIGRATION_NAME = os.path.splitext(os.path.basename(__file__))[0] diff --git a/cvat/apps/engine/migrations/0056_jobs_previews.py b/cvat/apps/engine/migrations/0056_jobs_previews.py index b8722018f92b..f3e6235fc780 100644 --- a/cvat/apps/engine/migrations/0056_jobs_previews.py +++ b/cvat/apps/engine/migrations/0056_jobs_previews.py @@ -2,8 +2,10 @@ import os import shutil -from django.db import migrations + from django.conf import settings +from django.db import migrations + from cvat.apps.engine.log import get_logger MIGRATION_NAME = os.path.splitext(os.path.basename(__file__))[0] diff --git a/cvat/apps/engine/migrations/0057_auto_20220726_0926.py b/cvat/apps/engine/migrations/0057_auto_20220726_0926.py index 459dbad6e783..22cd9f15e70b 100644 --- a/cvat/apps/engine/migrations/0057_auto_20220726_0926.py +++ b/cvat/apps/engine/migrations/0057_auto_20220726_0926.py @@ -1,8 +1,9 @@ # Generated by Django 3.2.14 on 2022-07-26 09:26 -import cvat.apps.engine.models -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models + +import cvat.apps.engine.models class Migration(migrations.Migration): diff --git a/cvat/apps/engine/migrations/0058_auto_20220809_1236.py b/cvat/apps/engine/migrations/0058_auto_20220809_1236.py index 8a7eb002d0af..aafb9a3bfab0 100644 --- a/cvat/apps/engine/migrations/0058_auto_20220809_1236.py +++ b/cvat/apps/engine/migrations/0058_auto_20220809_1236.py @@ -1,7 +1,7 @@ # Generated by Django 3.2.15 on 2022-08-09 12:36 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/cvat/apps/engine/migrations/0060_alter_label_parent.py b/cvat/apps/engine/migrations/0060_alter_label_parent.py index 5eb698343413..a5e8a8df31f3 100644 --- a/cvat/apps/engine/migrations/0060_alter_label_parent.py +++ b/cvat/apps/engine/migrations/0060_alter_label_parent.py @@ -1,7 +1,7 @@ # Generated by Django 3.2.15 on 2022-09-09 09:00 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/cvat/apps/engine/migrations/0062_delete_previews.py b/cvat/apps/engine/migrations/0062_delete_previews.py index da986be097fb..ccf5e8f9f176 100644 --- a/cvat/apps/engine/migrations/0062_delete_previews.py +++ b/cvat/apps/engine/migrations/0062_delete_previews.py @@ -2,10 +2,12 @@ import sys import traceback -from django.db import migrations from django.conf import settings +from django.db import migrations + from cvat.apps.engine.log import get_migration_logger + def delete_previews(apps, schema_editor): migration_name = os.path.splitext(os.path.basename(__file__))[0] with get_migration_logger(migration_name) as log: diff --git a/cvat/apps/engine/migrations/0064_delete_or_rename_wrong_labels.py b/cvat/apps/engine/migrations/0064_delete_or_rename_wrong_labels.py index 63c167381529..97cad2c4f565 100644 --- a/cvat/apps/engine/migrations/0064_delete_or_rename_wrong_labels.py +++ b/cvat/apps/engine/migrations/0064_delete_or_rename_wrong_labels.py @@ -1,8 +1,10 @@ import os from django.db import migrations + from cvat.apps.engine.log import get_migration_logger + def delete_or_rename_wrong_labels(apps, schema_editor): migration_name = os.path.splitext(os.path.basename(__file__))[0] with get_migration_logger(migration_name) as log: diff --git a/cvat/apps/engine/migrations/0070_add_job_type_created_date.py b/cvat/apps/engine/migrations/0070_add_job_type_created_date.py index 034a6b275ae9..62d0293245cf 100644 --- a/cvat/apps/engine/migrations/0070_add_job_type_created_date.py +++ b/cvat/apps/engine/migrations/0070_add_job_type_created_date.py @@ -1,6 +1,7 @@ -import cvat.apps.engine.models -from django.db import migrations, models import django.utils.timezone +from django.db import migrations, models + +import cvat.apps.engine.models def add_created_date_to_existing_jobs(apps, schema_editor): diff --git a/cvat/apps/engine/migrations/0071_annotationguide_asset.py b/cvat/apps/engine/migrations/0071_annotationguide_asset.py index 1060c4576aba..a6b50c50861b 100644 --- a/cvat/apps/engine/migrations/0071_annotationguide_asset.py +++ b/cvat/apps/engine/migrations/0071_annotationguide_asset.py @@ -1,9 +1,10 @@ # Generated by Django 3.2.18 on 2023-06-13 13:14 +import uuid + +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion -import uuid class Migration(migrations.Migration): diff --git a/cvat/apps/engine/migrations/0072_alter_issue_updated_date.py b/cvat/apps/engine/migrations/0072_alter_issue_updated_date.py index 4c549be10aa5..344036d12f65 100644 --- a/cvat/apps/engine/migrations/0072_alter_issue_updated_date.py +++ b/cvat/apps/engine/migrations/0072_alter_issue_updated_date.py @@ -2,6 +2,7 @@ from django.db import migrations, models + def forwards_func(apps, schema_editor): Issue = apps.get_model("engine", "Issue") diff --git a/cvat/apps/engine/migrations/0076_remove_storages_that_refer_to_deleted_cloud_storages.py b/cvat/apps/engine/migrations/0076_remove_storages_that_refer_to_deleted_cloud_storages.py index 50c1461319a7..41c902bb2500 100644 --- a/cvat/apps/engine/migrations/0076_remove_storages_that_refer_to_deleted_cloud_storages.py +++ b/cvat/apps/engine/migrations/0076_remove_storages_that_refer_to_deleted_cloud_storages.py @@ -1,6 +1,7 @@ # Generated by Django 4.2.6 on 2023-11-17 10:10 from django.db import migrations, models + from cvat.apps.engine.models import Location diff --git a/cvat/apps/engine/migrations/0077_auto_20231121_1952.py b/cvat/apps/engine/migrations/0077_auto_20231121_1952.py index 8b5c3648e068..831e83c8712a 100644 --- a/cvat/apps/engine/migrations/0077_auto_20231121_1952.py +++ b/cvat/apps/engine/migrations/0077_auto_20231121_1952.py @@ -1,7 +1,7 @@ # Generated by Django 4.2.6 on 2023-11-21 19:52 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/cvat/apps/engine/migrations/0079_alter_labeledimageattributeval_image_and_more.py b/cvat/apps/engine/migrations/0079_alter_labeledimageattributeval_image_and_more.py index ccafa6086b5e..58921bc97c92 100644 --- a/cvat/apps/engine/migrations/0079_alter_labeledimageattributeval_image_and_more.py +++ b/cvat/apps/engine/migrations/0079_alter_labeledimageattributeval_image_and_more.py @@ -1,7 +1,7 @@ # Generated by Django 4.2.13 on 2024-07-09 11:08 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/cvat/apps/engine/migrations/0080_alter_trackedshape_track.py b/cvat/apps/engine/migrations/0080_alter_trackedshape_track.py index d5997d15ff91..8266dbf4ba38 100644 --- a/cvat/apps/engine/migrations/0080_alter_trackedshape_track.py +++ b/cvat/apps/engine/migrations/0080_alter_trackedshape_track.py @@ -1,7 +1,7 @@ # Generated by Django 4.2.13 on 2024-07-12 19:01 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/cvat/apps/engine/migrations/0082_alter_labeledimage_job_and_more.py b/cvat/apps/engine/migrations/0082_alter_labeledimage_job_and_more.py index 50b91829b213..ecbc9d76f60d 100644 --- a/cvat/apps/engine/migrations/0082_alter_labeledimage_job_and_more.py +++ b/cvat/apps/engine/migrations/0082_alter_labeledimage_job_and_more.py @@ -1,7 +1,7 @@ # Generated by Django 4.2.14 on 2024-07-22 07:27 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/cvat/apps/engine/migrations/0085_segment_chunks_updated_date.py b/cvat/apps/engine/migrations/0085_segment_chunks_updated_date.py index 52342d7db774..6fed44b22a6a 100644 --- a/cvat/apps/engine/migrations/0085_segment_chunks_updated_date.py +++ b/cvat/apps/engine/migrations/0085_segment_chunks_updated_date.py @@ -1,6 +1,7 @@ # Generated by Django 4.2.15 on 2024-09-25 13:52 from datetime import datetime + from django.db import migrations, models diff --git a/cvat/apps/engine/mime_types.py b/cvat/apps/engine/mime_types.py index 8e70c5cc4193..fad18ba6b6f8 100644 --- a/cvat/apps/engine/mime_types.py +++ b/cvat/apps/engine/mime_types.py @@ -2,9 +2,8 @@ # # SPDX-License-Identifier: MIT -import os import mimetypes - +import os _SCRIPT_DIR = os.path.realpath(os.path.dirname(__file__)) MEDIA_MIMETYPES_FILES = [ diff --git a/cvat/apps/engine/mixins.py b/cvat/apps/engine/mixins.py index 39f50ed31db4..9e69ffdd5ccb 100644 --- a/cvat/apps/engine/mixins.py +++ b/cvat/apps/engine/mixins.py @@ -12,9 +12,9 @@ from dataclasses import asdict, dataclass from pathlib import Path from tempfile import NamedTemporaryFile -from unittest import mock from textwrap import dedent -from typing import Optional, Callable, Any +from typing import Any, Callable, Optional +from unittest import mock from urllib.parse import urljoin import django_rq @@ -22,20 +22,18 @@ from django.conf import settings from django.http import HttpRequest from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import (OpenApiParameter, OpenApiResponse, - extend_schema) +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from rest_framework import mixins, status -from rest_framework.decorators import action from rest_framework.authentication import SessionAuthentication +from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.views import APIView -from cvat.apps.engine.background import (BackupExportManager, - DatasetExportManager) +from cvat.apps.engine.background import BackupExportManager, DatasetExportManager from cvat.apps.engine.handlers import clear_import_cache from cvat.apps.engine.location import StorageType, get_location_configuration from cvat.apps.engine.log import ServerLogManager -from cvat.apps.engine.models import Location, RequestAction, RequestTarget, RequestSubresource +from cvat.apps.engine.models import Location, RequestAction, RequestSubresource, RequestTarget from cvat.apps.engine.rq_job_handler import RQId from cvat.apps.engine.serializers import DataSerializer, RqIdSerializer from cvat.apps.engine.utils import is_dataset_export diff --git a/cvat/apps/engine/pagination.py b/cvat/apps/engine/pagination.py index 2bb417f5c0d1..6a1dd499b893 100644 --- a/cvat/apps/engine/pagination.py +++ b/cvat/apps/engine/pagination.py @@ -3,8 +3,10 @@ # SPDX-License-Identifier: MIT import sys + from rest_framework.pagination import PageNumberPagination + class CustomPagination(PageNumberPagination): page_size_query_param = "page_size" diff --git a/cvat/apps/engine/parsers.py b/cvat/apps/engine/parsers.py index d0cecc4b02d0..03b4ebd45da8 100644 --- a/cvat/apps/engine/parsers.py +++ b/cvat/apps/engine/parsers.py @@ -4,6 +4,7 @@ from rest_framework.parsers import BaseParser + class TusUploadParser(BaseParser): # The media type is sent by TUS protocol (tus.io) for uploading files media_type = 'application/offset+octet-stream' diff --git a/cvat/apps/engine/permissions.py b/cvat/apps/engine/permissions.py index c5ddd4799c4c..a180410142cd 100644 --- a/cvat/apps/engine/permissions.py +++ b/cvat/apps/engine/permissions.py @@ -7,20 +7,23 @@ from collections.abc import Sequence from typing import Any, Optional, Union, cast -from django.shortcuts import get_object_or_404 from django.conf import settings - -from rest_framework.exceptions import ValidationError, PermissionDenied +from django.shortcuts import get_object_or_404 +from rest_framework.exceptions import PermissionDenied, ValidationError from rq.job import Job as RQJob from cvat.apps.engine.rq_job_handler import is_rq_job_owner +from cvat.apps.engine.utils import is_dataset_export from cvat.apps.iam.permissions import ( - OpenPolicyAgentPermission, StrEnum, get_iam_context, get_membership + OpenPolicyAgentPermission, + StrEnum, + get_iam_context, + get_membership, ) from cvat.apps.organizations.models import Organization from .models import AnnotationGuide, CloudStorage, Issue, Job, Label, Project, Task -from cvat.apps.engine.utils import is_dataset_export + def _get_key(d: dict[str, Any], key_path: Union[str, Sequence[str]]) -> Optional[Any]: """ diff --git a/cvat/apps/engine/renderers.py b/cvat/apps/engine/renderers.py index f56eb4d39808..542a322048ed 100644 --- a/cvat/apps/engine/renderers.py +++ b/cvat/apps/engine/renderers.py @@ -4,5 +4,6 @@ from rest_framework.renderers import JSONRenderer + class CVATAPIRenderer(JSONRenderer): media_type = 'application/vnd.cvat+json' diff --git a/cvat/apps/engine/rq_job_handler.py b/cvat/apps/engine/rq_job_handler.py index c5b31336ecdc..b4f146197afc 100644 --- a/cvat/apps/engine/rq_job_handler.py +++ b/cvat/apps/engine/rq_job_handler.py @@ -4,13 +4,14 @@ from __future__ import annotations -import attrs - -from typing import Optional, Union, Any +from typing import Any, Optional, Union from uuid import UUID + +import attrs from rq.job import Job as RQJob -from .models import RequestAction, RequestTarget, RequestSubresource +from .models import RequestAction, RequestSubresource, RequestTarget + class RQMeta: @staticmethod diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 9f772cd24e6d..395bf59108b9 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -5,48 +5,55 @@ from __future__ import annotations +import os +import re +import shutil +import string +import textwrap +import warnings from collections import OrderedDict from collections.abc import Iterable, Sequence from contextlib import closing -import warnings from copy import copy from datetime import timedelta from decimal import Decimal from inspect import isclass -import os -import re -import shutil -import string from tempfile import NamedTemporaryFile -import textwrap from typing import Any, Optional, Union import django_rq +import rq.defaults as rq_defaults from django.conf import settings -from django.contrib.auth.models import User, Group +from django.contrib.auth.models import Group, User from django.db import transaction -from django.db.models import prefetch_related_objects, Prefetch +from django.db.models import Prefetch, prefetch_related_objects from django.utils import timezone +from drf_spectacular.utils import OpenApiExample, extend_schema_field, extend_schema_serializer from numpy import random -from rest_framework import serializers, exceptions -import rq.defaults as rq_defaults -from rq.job import Job as RQJob, JobStatus as RQJobStatus +from rest_framework import exceptions, serializers +from rq.job import Job as RQJob +from rq.job import JobStatus as RQJobStatus from cvat.apps.dataset_manager.formats.utils import get_label_color from cvat.apps.engine import field_validation, models -from cvat.apps.engine.frame_provider import TaskFrameProvider, FrameQuality -from cvat.apps.engine.cloud_provider import get_cloud_storage_instance, Credentials, Status +from cvat.apps.engine.cloud_provider import Credentials, Status, get_cloud_storage_instance +from cvat.apps.engine.frame_provider import FrameQuality, TaskFrameProvider from cvat.apps.engine.log import ServerLogManager from cvat.apps.engine.permissions import TaskPermission +from cvat.apps.engine.rq_job_handler import RQId, RQJobMetaField from cvat.apps.engine.task_validation import HoneypotFrameSelector -from cvat.apps.engine.rq_job_handler import RQJobMetaField, RQId from cvat.apps.engine.utils import ( - format_list, grouped, parse_exception_message, CvatChunkTimestampMismatchError, - parse_specific_attributes, build_field_filter_params, get_list_view_name, reverse, take_by + CvatChunkTimestampMismatchError, + build_field_filter_params, + format_list, + get_list_view_name, + grouped, + parse_exception_message, + parse_specific_attributes, + reverse, + take_by, ) -from drf_spectacular.utils import OpenApiExample, extend_schema_field, extend_schema_serializer - slogger = ServerLogManager(__name__) class WriteOnceMixin: @@ -996,7 +1003,10 @@ def validate(self, attrs): @transaction.atomic def update(self, instance: models.Job, validated_data: dict[str, Any]) -> models.Job: from cvat.apps.engine.cache import ( - MediaCache, Callback, enqueue_create_chunk_job, wait_for_rq_job + Callback, + MediaCache, + enqueue_create_chunk_job, + wait_for_rq_job, ) from cvat.apps.engine.frame_provider import JobFrameProvider diff --git a/cvat/apps/engine/signals.py b/cvat/apps/engine/signals.py index 3a964d90c2cc..456c6f228081 100644 --- a/cvat/apps/engine/signals.py +++ b/cvat/apps/engine/signals.py @@ -11,8 +11,7 @@ from django.db.models.signals import m2m_changed, post_delete, post_save from django.dispatch import receiver -from .models import CloudStorage, Data, Job, Profile, Project, StatusChoice, Task, Asset - +from .models import Asset, CloudStorage, Data, Job, Profile, Project, StatusChoice, Task # TODO: need to log any problems reported by shutil.rmtree when the new # analytics feature is available. Now the log system can write information diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 0f36674299fc..7aa92acba2fd 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -4,24 +4,24 @@ # SPDX-License-Identifier: MIT import concurrent.futures -import itertools import fnmatch +import itertools import os import re -import rq import shutil from collections.abc import Iterator, Sequence -from copy import deepcopy from contextlib import closing +from copy import deepcopy from datetime import datetime, timezone from pathlib import Path from typing import Any, NamedTuple, Optional, Union from urllib import parse as urlparse from urllib import request as urlrequest -import av import attrs +import av import django_rq +import rq from django.conf import settings from django.db import transaction from django.forms.models import model_to_dict @@ -29,25 +29,39 @@ from rest_framework.serializers import ValidationError from cvat.apps.engine import models -from cvat.apps.engine.log import ServerLogManager from cvat.apps.engine.frame_provider import TaskFrameProvider +from cvat.apps.engine.log import ServerLogManager from cvat.apps.engine.media_extractors import ( - MEDIA_TYPES, CachingMediaIterator, IMediaReader, ImageListReader, - Mpeg4ChunkWriter, Mpeg4CompressedChunkWriter, RandomAccessIterator, - ValidateDimension, ZipChunkWriter, ZipCompressedChunkWriter, get_mime, sort, + MEDIA_TYPES, + CachingMediaIterator, + ImageListReader, + IMediaReader, + Mpeg4ChunkWriter, + Mpeg4CompressedChunkWriter, + RandomAccessIterator, + ValidateDimension, + ZipChunkWriter, + ZipCompressedChunkWriter, + get_mime, load_image, + sort, ) from cvat.apps.engine.models import RequestAction, RequestTarget -from cvat.apps.engine.utils import ( - av_scan_paths, format_list, get_rq_job_meta, - define_dependent_job, get_rq_lock_by_user, take_by -) from cvat.apps.engine.rq_job_handler import RQId from cvat.apps.engine.task_validation import HoneypotFrameSelector -from cvat.utils.http import make_requests_session, PROXIES_FOR_UNTRUSTED_URLS +from cvat.apps.engine.utils import ( + av_scan_paths, + define_dependent_job, + format_list, + get_rq_job_meta, + get_rq_lock_by_user, + take_by, +) +from cvat.utils.http import PROXIES_FOR_UNTRUSTED_URLS, make_requests_session from utils.dataset_manifest import ImageManifestManager, VideoManifestManager, is_manifest from utils.dataset_manifest.core import VideoManifestValidator, is_dataset_manifest from utils.dataset_manifest.utils import detect_related_images + from .cloud_provider import db_storage_to_storage_instance slogger = ServerLogManager(__name__) diff --git a/cvat/apps/engine/tests/test_lazy_list.py b/cvat/apps/engine/tests/test_lazy_list.py index 6ba4b07dd38f..2a021f89b94a 100644 --- a/cvat/apps/engine/tests/test_lazy_list.py +++ b/cvat/apps/engine/tests/test_lazy_list.py @@ -1,9 +1,9 @@ -import unittest import copy import pickle +import unittest from typing import TypeVar -from cvat.apps.engine.lazy_list import LazyList +from cvat.apps.engine.lazy_list import LazyList T = TypeVar('T') diff --git a/cvat/apps/engine/tests/test_rest_api.py b/cvat/apps/engine/tests/test_rest_api.py index b0c5500eda4c..3a2f5d6e8a82 100644 --- a/cvat/apps/engine/tests/test_rest_api.py +++ b/cvat/apps/engine/tests/test_rest_api.py @@ -3,10 +3,10 @@ # # SPDX-License-Identifier: MIT -from contextlib import ExitStack -from datetime import timedelta +import copy import io -from itertools import product +import json +import logging import os import random import shutil @@ -15,40 +15,56 @@ import xml.etree.ElementTree as ET import zipfile from collections import defaultdict +from contextlib import ExitStack +from datetime import timedelta from enum import Enum from glob import glob from io import BytesIO, IOBase -from unittest import mock +from itertools import product from time import sleep -import logging -import copy -import json +from unittest import mock import av import django_rq import numpy as np -from pdf2image import convert_from_bytes -from pyunpack import Archive from django.conf import settings from django.contrib.auth.models import Group, User from django.http import HttpResponse +from pdf2image import convert_from_bytes from PIL import Image from pycocotools import coco as coco_loader +from pyunpack import Archive from rest_framework import status from rest_framework.test import APIClient from cvat.apps.dataset_manager.tests.utils import TestDir from cvat.apps.dataset_manager.util import current_function_name -from cvat.apps.engine.models import (AttributeSpec, AttributeType, Data, Job, - Project, Segment, StageChoice, StatusChoice, Task, Label, StorageMethodChoice, - StorageChoice, DimensionType, SortingMethod) from cvat.apps.engine.media_extractors import ValidateDimension, sort -from cvat.apps.engine.tests.utils import get_paginated_collection +from cvat.apps.engine.models import ( + AttributeSpec, + AttributeType, + Data, + DimensionType, + Job, + Label, + Project, + Segment, + SortingMethod, + StageChoice, + StatusChoice, + StorageChoice, + StorageMethodChoice, + Task, +) +from cvat.apps.engine.tests.utils import ( + ApiTestBase, + ForceLogin, + generate_image_file, + generate_video_file, + get_paginated_collection, +) from utils.dataset_manifest import ImageManifestManager, VideoManifestManager -from cvat.apps.engine.tests.utils import (ApiTestBase, ForceLogin, - generate_image_file, generate_video_file) - #suppress av warnings logging.getLogger('libav').setLevel(logging.ERROR) diff --git a/cvat/apps/engine/tests/test_rest_api_3D.py b/cvat/apps/engine/tests/test_rest_api_3D.py index 67791c3c113c..087448c90dd2 100644 --- a/cvat/apps/engine/tests/test_rest_api_3D.py +++ b/cvat/apps/engine/tests/test_rest_api_3D.py @@ -4,7 +4,9 @@ # SPDX-License-Identifier: MIT +import copy import io +import itertools import os import os.path as osp import tempfile @@ -13,18 +15,15 @@ from collections import defaultdict from glob import glob from io import BytesIO -import copy from shutil import copyfile -import itertools from django.contrib.auth.models import Group, User from rest_framework import status +from cvat.apps.dataset_manager.task import TaskAnnotation from cvat.apps.dataset_manager.tests.utils import TestDir from cvat.apps.engine.media_extractors import ValidateDimension -from cvat.apps.dataset_manager.task import TaskAnnotation - -from cvat.apps.engine.tests.utils import get_paginated_collection, ApiTestBase, ForceLogin +from cvat.apps.engine.tests.utils import ApiTestBase, ForceLogin, get_paginated_collection CREATE_ACTION = "create" UPDATE_ACTION = "update" diff --git a/cvat/apps/engine/tests/utils.py b/cvat/apps/engine/tests/utils.py index 910323cac1f7..09fd850b2c19 100644 --- a/cvat/apps/engine/tests/utils.py +++ b/cvat/apps/engine/tests/utils.py @@ -2,22 +2,22 @@ # # SPDX-License-Identifier: MIT +import itertools +import logging +import os from collections.abc import Iterator, Sequence from contextlib import contextmanager from io import BytesIO from typing import Any, Callable, TypeVar -import itertools -import logging -import os +import av +import django_rq +import numpy as np from django.conf import settings from django.core.cache import caches from django.http.response import HttpResponse from PIL import Image from rest_framework.test import APITestCase -import av -import django_rq -import numpy as np T = TypeVar('T') diff --git a/cvat/apps/engine/urls.py b/cvat/apps/engine/urls.py index 1755197ebcdf..1380ae5f7961 100644 --- a/cvat/apps/engine/urls.py +++ b/cvat/apps/engine/urls.py @@ -3,14 +3,13 @@ # # SPDX-License-Identifier: MIT -from django.urls import path, include -from . import views -from rest_framework import routers - -from django.views.generic import RedirectView from django.conf import settings - +from django.urls import include, path +from django.views.generic import RedirectView from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView +from rest_framework import routers + +from . import views router = routers.DefaultRouter(trailing_slash=False) router.register('projects', views.ProjectViewSet) diff --git a/cvat/apps/engine/utils.py b/cvat/apps/engine/utils.py index dd4533538f5a..b3e3d48f69d6 100644 --- a/cvat/apps/engine/utils.py +++ b/cvat/apps/engine/utils.py @@ -4,42 +4,39 @@ # SPDX-License-Identifier: MIT import ast -from itertools import islice -import cv2 as cv -from collections import namedtuple -from collections.abc import Generator, Iterable, Iterator, Mapping, Sequence import hashlib import importlib +import logging +import os +import platform +import re +import subprocess import sys import traceback -from contextlib import suppress, nullcontext -from typing import Any, Callable, Optional, TypeVar, Union -import subprocess -import os import urllib.parse -import re -import logging -import platform +from collections import namedtuple +from collections.abc import Generator, Iterable, Iterator, Mapping, Sequence +from contextlib import nullcontext, suppress +from itertools import islice +from multiprocessing import cpu_count +from pathlib import Path +from typing import Any, Callable, Optional, TypeVar, Union +import cv2 as cv from attr.converters import to_bool +from av import VideoFrame from datumaro.util.os_util import walk -from rq.job import Job, Dependency -from django_rq.queues import DjangoRQ -from pathlib import Path - +from django.conf import settings +from django.core.exceptions import ValidationError from django.http.request import HttpRequest from django.utils import timezone from django.utils.http import urlencode -from rest_framework.reverse import reverse as _reverse - -from av import VideoFrame -from PIL import Image -from multiprocessing import cpu_count - -from django.core.exceptions import ValidationError +from django_rq.queues import DjangoRQ from django_sendfile import sendfile as _sendfile -from django.conf import settings +from PIL import Image from redis.lock import Lock +from rest_framework.reverse import reverse as _reverse +from rq.job import Dependency, Job Import = namedtuple("Import", ["module", "name", "alias"]) @@ -230,8 +227,8 @@ def get_rq_job_meta( result_url: Optional[str] = None, ): # to prevent circular import - from cvat.apps.webhooks.signals import project_id, organization_id - from cvat.apps.events.handlers import task_id, job_id, organization_slug + from cvat.apps.events.handlers import job_id, organization_slug, task_id + from cvat.apps.webhooks.signals import organization_id, project_id oid = organization_id(db_obj) oslug = organization_slug(db_obj) diff --git a/cvat/apps/engine/view_utils.py b/cvat/apps/engine/view_utils.py index 6f5dc298a7b6..dbac90720b43 100644 --- a/cvat/apps/engine/view_utils.py +++ b/cvat/apps/engine/view_utils.py @@ -9,11 +9,11 @@ from django.db.models.query import QuerySet from django.http.request import HttpRequest from django.http.response import HttpResponse +from drf_spectacular.utils import extend_schema from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.serializers import Serializer from rest_framework.viewsets import GenericViewSet -from drf_spectacular.utils import extend_schema from cvat.apps.engine.mixins import UploadMixin from cvat.apps.engine.parsers import TusUploadParser diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index eb39f6732c18..6b70836d53a1 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -13,105 +13,173 @@ import traceback import zlib from abc import ABCMeta, abstractmethod -from contextlib import suppress -from PIL import Image -from types import SimpleNamespace -from typing import Optional, Any, Union, cast, Callable from collections import namedtuple -from collections.abc import Mapping, Iterable +from collections.abc import Iterable, Mapping +from contextlib import suppress from copy import copy from datetime import datetime -from redis.exceptions import ConnectionError as RedisConnectionError +from pathlib import Path from tempfile import NamedTemporaryFile +from types import SimpleNamespace +from typing import Any, Callable, Optional, Union, cast import django_rq from attr.converters import to_bool from django.conf import settings from django.contrib.auth.models import User -from django.db import IntegrityError, transaction +from django.db import IntegrityError from django.db import models as django_models +from django.db import transaction from django.db.models.query import Prefetch -from django.http import HttpResponse, HttpRequest, HttpResponseNotFound, HttpResponseBadRequest +from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest, HttpResponseNotFound from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.cache import never_cache from django_rq.queues import DjangoRQ - from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import ( - OpenApiExample, OpenApiParameter, OpenApiResponse, PolymorphicProxySerializer, - extend_schema_view, extend_schema + OpenApiExample, + OpenApiParameter, + OpenApiResponse, + PolymorphicProxySerializer, + extend_schema, + extend_schema_view, ) - -from pathlib import Path +from PIL import Image +from redis.exceptions import ConnectionError as RedisConnectionError from rest_framework import mixins, serializers, status, viewsets from rest_framework.decorators import action -from rest_framework.exceptions import APIException, NotFound, ValidationError, PermissionDenied +from rest_framework.exceptions import APIException, NotFound, PermissionDenied, ValidationError from rest_framework.parsers import MultiPartParser from rest_framework.permissions import SAFE_METHODS from rest_framework.response import Response from rest_framework.settings import api_settings - -from rq.job import Job as RQJob, JobStatus as RQJobStatus +from rq.job import Job as RQJob +from rq.job import JobStatus as RQJobStatus import cvat.apps.dataset_manager as dm import cvat.apps.dataset_manager.views # pylint: disable=unused-import -from cvat.apps.engine.cloud_provider import db_storage_to_storage_instance, import_resource_from_cloud_storage -from cvat.apps.events.handlers import handle_dataset_import from cvat.apps.dataset_manager.bindings import CvatImportError from cvat.apps.dataset_manager.serializers import DatasetFormatsSerializer +from cvat.apps.engine import backup +from cvat.apps.engine.cache import CvatChunkTimestampMismatchError, LockError, MediaCache +from cvat.apps.engine.cloud_provider import ( + db_storage_to_storage_instance, + import_resource_from_cloud_storage, +) +from cvat.apps.engine.filters import ( + NonModelJsonLogicFilter, + NonModelOrderingFilter, + NonModelSimpleFilter, +) from cvat.apps.engine.frame_provider import ( - DataWithMeta, IFrameProvider, TaskFrameProvider, JobFrameProvider, FrameQuality + DataWithMeta, + FrameQuality, + IFrameProvider, + JobFrameProvider, + TaskFrameProvider, ) -from cvat.apps.engine.filters import NonModelSimpleFilter, NonModelOrderingFilter, NonModelJsonLogicFilter +from cvat.apps.engine.location import StorageType, get_location_configuration from cvat.apps.engine.media_extractors import get_mime -from cvat.apps.engine.permissions import AnnotationGuidePermission, get_iam_context +from cvat.apps.engine.mixins import ( + BackupMixin, + CsrfWorkaroundMixin, + DatasetMixin, + PartialUpdateModelMixin, + UploadMixin, +) +from cvat.apps.engine.models import AnnotationGuide, Asset, ClientFile, CloudProviderChoice +from cvat.apps.engine.models import CloudStorage as CloudStorageModel from cvat.apps.engine.models import ( - ClientFile, Job, JobType, Label, Task, Project, Issue, Data, - Comment, StorageMethodChoice, StorageChoice, - CloudProviderChoice, Location, CloudStorage as CloudStorageModel, - Asset, AnnotationGuide, RequestStatus, RequestAction, RequestTarget, RequestSubresource + Comment, + Data, + Issue, + Job, + JobType, + Label, + Location, + Project, + RequestAction, + RequestStatus, + RequestSubresource, + RequestTarget, + StorageChoice, + StorageMethodChoice, + Task, +) +from cvat.apps.engine.permissions import ( + AnnotationGuidePermission, + CloudStoragePermission, + CommentPermission, + IssuePermission, + JobPermission, + LabelPermission, + ProjectPermission, + TaskPermission, + UserPermission, + get_cloud_storage_for_import_or_export, + get_iam_context, ) +from cvat.apps.engine.rq_job_handler import RQId, RQJobMetaField, is_rq_job_owner from cvat.apps.engine.serializers import ( - AboutSerializer, AnnotationFileSerializer, BasicUserSerializer, - DataMetaReadSerializer, DataMetaWriteSerializer, DataSerializer, FileInfoSerializer, - JobDataMetaWriteSerializer, JobReadSerializer, JobWriteSerializer, - JobValidationLayoutReadSerializer, JobValidationLayoutWriteSerializer, - LabelSerializer, LabeledDataSerializer, - ProjectReadSerializer, ProjectWriteSerializer, - RqStatusSerializer, TaskReadSerializer, TaskValidationLayoutReadSerializer, TaskValidationLayoutWriteSerializer, TaskWriteSerializer, - UserSerializer, PluginsSerializer, IssueReadSerializer, - AnnotationGuideReadSerializer, AnnotationGuideWriteSerializer, - AssetReadSerializer, AssetWriteSerializer, - IssueWriteSerializer, CommentReadSerializer, CommentWriteSerializer, CloudStorageWriteSerializer, - CloudStorageReadSerializer, DatasetFileSerializer, - ProjectFileSerializer, TaskFileSerializer, RqIdSerializer, CloudStorageContentSerializer, + AboutSerializer, + AnnotationFileSerializer, + AnnotationGuideReadSerializer, + AnnotationGuideWriteSerializer, + AssetReadSerializer, + AssetWriteSerializer, + BasicUserSerializer, + CloudStorageContentSerializer, + CloudStorageReadSerializer, + CloudStorageWriteSerializer, + CommentReadSerializer, + CommentWriteSerializer, + DataMetaReadSerializer, + DataMetaWriteSerializer, + DataSerializer, + DatasetFileSerializer, + FileInfoSerializer, + IssueReadSerializer, + IssueWriteSerializer, + JobDataMetaWriteSerializer, + JobReadSerializer, + JobValidationLayoutReadSerializer, + JobValidationLayoutWriteSerializer, + JobWriteSerializer, + LabeledDataSerializer, + LabelSerializer, + PluginsSerializer, + ProjectFileSerializer, + ProjectReadSerializer, + ProjectWriteSerializer, RequestSerializer, + RqIdSerializer, + RqStatusSerializer, + TaskFileSerializer, + TaskReadSerializer, + TaskValidationLayoutReadSerializer, + TaskValidationLayoutWriteSerializer, + TaskWriteSerializer, + UserSerializer, ) -from cvat.apps.engine.permissions import get_cloud_storage_for_import_or_export - -from utils.dataset_manifest import ImageManifestManager from cvat.apps.engine.utils import ( - av_scan_paths, process_failed_job, - parse_exception_message, get_rq_job_meta, - import_resource_with_clean_up_after, sendfile, define_dependent_job, get_rq_lock_by_user, -) -from cvat.apps.engine.rq_job_handler import RQId, is_rq_job_owner, RQJobMetaField -from cvat.apps.engine import backup -from cvat.apps.engine.mixins import ( - PartialUpdateModelMixin, UploadMixin, DatasetMixin, BackupMixin, CsrfWorkaroundMixin + av_scan_paths, + define_dependent_job, + get_rq_job_meta, + get_rq_lock_by_user, + import_resource_with_clean_up_after, + parse_exception_message, + process_failed_job, + sendfile, ) -from cvat.apps.engine.location import get_location_configuration, StorageType +from cvat.apps.engine.view_utils import tus_chunk_action +from cvat.apps.events.handlers import handle_dataset_import +from cvat.apps.iam.filters import ORGANIZATION_OPEN_API_PARAMETERS +from cvat.apps.iam.permissions import IsAuthenticatedOrReadPublicResource, PolicyEnforcer +from utils.dataset_manifest import ImageManifestManager from . import models, task from .log import ServerLogManager -from cvat.apps.iam.filters import ORGANIZATION_OPEN_API_PARAMETERS -from cvat.apps.iam.permissions import PolicyEnforcer, IsAuthenticatedOrReadPublicResource -from cvat.apps.engine.cache import MediaCache, CvatChunkTimestampMismatchError, LockError -from cvat.apps.engine.permissions import (CloudStoragePermission, - CommentPermission, IssuePermission, JobPermission, LabelPermission, ProjectPermission, - TaskPermission, UserPermission) -from cvat.apps.engine.view_utils import tus_chunk_action slogger = ServerLogManager(__name__) diff --git a/cvat/apps/events/apps.py b/cvat/apps/events/apps.py index f700758ad204..17c42e754f1b 100644 --- a/cvat/apps/events/apps.py +++ b/cvat/apps/events/apps.py @@ -9,7 +9,7 @@ class EventsConfig(AppConfig): name = 'cvat.apps.events' def ready(self): - from . import signals # pylint: disable=unused-import - from cvat.apps.iam.permissions import load_app_permissions load_app_permissions(self) + + from . import signals # pylint: disable=unused-import diff --git a/cvat/apps/events/cache.py b/cvat/apps/events/cache.py index 30d1e67b8fc1..168211a1e24f 100644 --- a/cvat/apps/events/cache.py +++ b/cvat/apps/events/cache.py @@ -6,7 +6,7 @@ class DeleteCache(): def __init__(self, cache_id): - from cvat.apps.engine.models import Task, Job, Issue, Comment + from cvat.apps.engine.models import Comment, Issue, Job, Task self._cache = _caches.setdefault(cache_id, { Task: {}, Job: {}, diff --git a/cvat/apps/events/event.py b/cvat/apps/events/event.py index a4afff968549..ebb00496b36c 100644 --- a/cvat/apps/events/event.py +++ b/cvat/apps/events/event.py @@ -2,14 +2,15 @@ # # SPDX-License-Identifier: MIT -from rest_framework.renderers import JSONRenderer from datetime import datetime, timezone from typing import Optional from django.db import transaction +from rest_framework.renderers import JSONRenderer from cvat.apps.engine.log import vlogger + def event_scope(action, resource): return f"{action}:{resource}" diff --git a/cvat/apps/events/export.py b/cvat/apps/events/export.py index 9225f1141162..aa70fcdc066e 100644 --- a/cvat/apps/events/export.py +++ b/cvat/apps/events/export.py @@ -2,25 +2,23 @@ # # SPDX-License-Identifier: MIT -from logging import Logger -import os import csv -from datetime import datetime, timedelta, timezone -from dateutil import parser +import os import uuid +from datetime import datetime, timedelta, timezone +from logging import Logger +import clickhouse_connect import django_rq +from dateutil import parser from django.conf import settings -import clickhouse_connect - - from rest_framework import serializers, status from rest_framework.response import Response from cvat.apps.dataset_manager.views import log_exception from cvat.apps.engine.log import ServerLogManager -from cvat.apps.engine.utils import sendfile from cvat.apps.engine.rq_job_handler import RQJobMetaField +from cvat.apps.engine.utils import sendfile slogger = ServerLogManager(__name__) diff --git a/cvat/apps/events/handlers.py b/cvat/apps/events/handlers.py index 753eb84dd0da..58581dacaa37 100644 --- a/cvat/apps/events/handlers.py +++ b/cvat/apps/events/handlers.py @@ -11,26 +11,40 @@ from rest_framework.exceptions import NotAuthenticated from rest_framework.views import exception_handler -from cvat.apps.engine.models import (CloudStorage, Comment, Issue, Job, Label, - Project, ShapeType, Task, User) -from cvat.apps.engine.serializers import (BasicUserSerializer, - CloudStorageReadSerializer, - CommentReadSerializer, - IssueReadSerializer, - JobReadSerializer, LabelSerializer, - ProjectReadSerializer, - TaskReadSerializer) -from cvat.apps.organizations.models import Invitation, Membership, Organization -from cvat.apps.organizations.serializers import (InvitationReadSerializer, - MembershipReadSerializer, - OrganizationReadSerializer) +from cvat.apps.engine.models import ( + CloudStorage, + Comment, + Issue, + Job, + Label, + Project, + ShapeType, + Task, + User, +) from cvat.apps.engine.rq_job_handler import RQJobMetaField +from cvat.apps.engine.serializers import ( + BasicUserSerializer, + CloudStorageReadSerializer, + CommentReadSerializer, + IssueReadSerializer, + JobReadSerializer, + LabelSerializer, + ProjectReadSerializer, + TaskReadSerializer, +) +from cvat.apps.organizations.models import Invitation, Membership, Organization +from cvat.apps.organizations.serializers import ( + InvitationReadSerializer, + MembershipReadSerializer, + OrganizationReadSerializer, +) from cvat.apps.webhooks.models import Webhook from cvat.apps.webhooks.serializers import WebhookReadSerializer from .cache import get_cache -from .event import event_scope, record_server_event from .const import WORKING_TIME_RESOLUTION, WORKING_TIME_SCOPE +from .event import event_scope, record_server_event from .utils import compute_working_time_per_ids diff --git a/cvat/apps/events/permissions.py b/cvat/apps/events/permissions.py index a1b049cbbd4b..eb5ea2281683 100644 --- a/cvat/apps/events/permissions.py +++ b/cvat/apps/events/permissions.py @@ -4,12 +4,12 @@ # SPDX-License-Identifier: MIT from django.conf import settings - from rest_framework.exceptions import PermissionDenied from cvat.apps.iam.permissions import OpenPolicyAgentPermission, StrEnum from cvat.utils.http import make_requests_session + class EventsPermission(OpenPolicyAgentPermission): class Scopes(StrEnum): SEND_EVENTS = 'send:events' diff --git a/cvat/apps/events/tests/test_events.py b/cvat/apps/events/tests/test_events.py index 71dcc1ea89b0..28ca3f0a20e5 100644 --- a/cvat/apps/events/tests/test_events.py +++ b/cvat/apps/events/tests/test_events.py @@ -9,10 +9,11 @@ from django.contrib.auth import get_user_model from django.test import RequestFactory -from cvat.apps.events.serializers import ClientEventsSerializer -from cvat.apps.organizations.models import Organization from cvat.apps.events.const import MAX_EVENT_DURATION, WORKING_TIME_RESOLUTION +from cvat.apps.events.serializers import ClientEventsSerializer from cvat.apps.events.utils import compute_working_time_per_ids, is_contained +from cvat.apps.organizations.models import Organization + class WorkingTimeTestCase(unittest.TestCase): _START_TIMESTAMP = datetime(2024, 1, 1, 12) diff --git a/cvat/apps/events/utils.py b/cvat/apps/events/utils.py index 745bb8fde316..2a46c48beb38 100644 --- a/cvat/apps/events/utils.py +++ b/cvat/apps/events/utils.py @@ -4,13 +4,12 @@ import datetime - -from .const import MAX_EVENT_DURATION, COMPRESSED_EVENT_SCOPES from .cache import clear_cache +from .const import COMPRESSED_EVENT_SCOPES, MAX_EVENT_DURATION def _prepare_objects_to_delete(object_to_delete): - from cvat.apps.engine.models import Project, Task, Segment, Job, Issue, Comment + from cvat.apps.engine.models import Comment, Issue, Job, Project, Segment, Task relation_chain = (Project, Task, Segment, Job, Issue, Comment) related_field_names = ('task_set', 'segment_set', 'job_set', 'issues', 'comments') diff --git a/cvat/apps/events/views.py b/cvat/apps/events/views.py index 31914a829c3b..ea1f967f81ed 100644 --- a/cvat/apps/events/views.py +++ b/cvat/apps/events/views.py @@ -4,8 +4,7 @@ from django.conf import settings from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import (OpenApiParameter, OpenApiResponse, - extend_schema) +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from rest_framework import status, viewsets from rest_framework.renderers import JSONRenderer from rest_framework.response import Response diff --git a/cvat/apps/health/apps.py b/cvat/apps/health/apps.py index a457048b87c9..e81a24ae6374 100644 --- a/cvat/apps/health/apps.py +++ b/cvat/apps/health/apps.py @@ -3,9 +3,9 @@ # SPDX-License-Identifier: MIT from django.apps import AppConfig - from health_check.plugins import plugin_dir + class HealthConfig(AppConfig): name = 'cvat.apps.health' diff --git a/cvat/apps/health/backends.py b/cvat/apps/health/backends.py index 2f361117173a..48484c175df0 100644 --- a/cvat/apps/health/backends.py +++ b/cvat/apps/health/backends.py @@ -3,14 +3,13 @@ # SPDX-License-Identifier: MIT import requests - +from django.conf import settings from health_check.backends import BaseHealthCheckBackend from health_check.exceptions import HealthCheckException -from django.conf import settings - from cvat.utils.http import make_requests_session + class OPAHealthCheck(BaseHealthCheckBackend): critical_service = True diff --git a/cvat/apps/health/management/commands/workerprobe.py b/cvat/apps/health/management/commands/workerprobe.py index fc8b6cf7077a..4cf6bb58d9f2 100644 --- a/cvat/apps/health/management/commands/workerprobe.py +++ b/cvat/apps/health/management/commands/workerprobe.py @@ -1,10 +1,11 @@ import os import platform from datetime import datetime, timedelta -from django.core.management.base import BaseCommand, CommandError + +import django_rq from django.conf import settings +from django.core.management.base import BaseCommand, CommandError from rq.worker import Worker -import django_rq class Command(BaseCommand): diff --git a/cvat/apps/iam/adapters.py b/cvat/apps/iam/adapters.py index 703bec48743f..50ff2812c3a5 100644 --- a/cvat/apps/iam/adapters.py +++ b/cvat/apps/iam/adapters.py @@ -2,10 +2,10 @@ # # SPDX-License-Identifier: MIT -from django.http import HttpResponseRedirect +from allauth.account.adapter import DefaultAccountAdapter from django.conf import settings +from django.http import HttpResponseRedirect -from allauth.account.adapter import DefaultAccountAdapter class DefaultAccountAdapterEx(DefaultAccountAdapter): def respond_email_verification_sent(self, request, user): diff --git a/cvat/apps/iam/admin.py b/cvat/apps/iam/admin.py index 648e15dc2da4..849b76077d14 100644 --- a/cvat/apps/iam/admin.py +++ b/cvat/apps/iam/admin.py @@ -4,8 +4,8 @@ # SPDX-License-Identifier: MIT from django.contrib import admin -from django.contrib.auth.models import Group, User from django.contrib.auth.admin import GroupAdmin, UserAdmin +from django.contrib.auth.models import Group, User from django.utils.translation import gettext_lazy as _ from cvat.apps.engine.models import Profile diff --git a/cvat/apps/iam/apps.py b/cvat/apps/iam/apps.py index 97bdc3ca05fd..e89e959256ee 100644 --- a/cvat/apps/iam/apps.py +++ b/cvat/apps/iam/apps.py @@ -5,6 +5,7 @@ from django.apps import AppConfig + class IAMConfig(AppConfig): name = 'cvat.apps.iam' diff --git a/cvat/apps/iam/authentication.py b/cvat/apps/iam/authentication.py index 412806380389..79ee8e0671b0 100644 --- a/cvat/apps/iam/authentication.py +++ b/cvat/apps/iam/authentication.py @@ -2,12 +2,14 @@ # # SPDX-License-Identifier: MIT +import hashlib + +from django.contrib.auth import get_user_model from django.core import signing +from furl import furl from rest_framework import exceptions from rest_framework.authentication import BaseAuthentication -from django.contrib.auth import get_user_model -from furl import furl -import hashlib + # Got implementation ideas in https://github.com/marcgibbons/drf_signed_auth class Signer: diff --git a/cvat/apps/iam/filters.py b/cvat/apps/iam/filters.py index 6fd62d8d05e5..73a6268ba426 100644 --- a/cvat/apps/iam/filters.py +++ b/cvat/apps/iam/filters.py @@ -2,11 +2,11 @@ # # SPDX-License-Identifier: MIT -from rest_framework.filters import BaseFilterBackend -from django.db.models import Q from collections.abc import Iterable +from django.db.models import Q from drf_spectacular.utils import OpenApiParameter +from rest_framework.filters import BaseFilterBackend ORGANIZATION_OPEN_API_PARAMETERS = [ OpenApiParameter( diff --git a/cvat/apps/iam/forms.py b/cvat/apps/iam/forms.py index c1668b924387..d46f35cc4e9e 100644 --- a/cvat/apps/iam/forms.py +++ b/cvat/apps/iam/forms.py @@ -2,13 +2,12 @@ # # SPDX-License-Identifier: MIT -from django.contrib.sites.shortcuts import get_current_site -from django.contrib.auth import get_user_model - +from allauth.account.adapter import get_adapter from allauth.account.forms import default_token_generator from allauth.account.utils import user_pk_to_url_str -from allauth.account.adapter import get_adapter from dj_rest_auth.forms import AllAuthPasswordResetForm +from django.contrib.auth import get_user_model +from django.contrib.sites.shortcuts import get_current_site UserModel = get_user_model() diff --git a/cvat/apps/iam/middleware.py b/cvat/apps/iam/middleware.py index f2f1a4bae2e0..d96a8e364086 100644 --- a/cvat/apps/iam/middleware.py +++ b/cvat/apps/iam/middleware.py @@ -5,10 +5,10 @@ from datetime import timedelta from typing import Callable -from django.utils.functional import SimpleLazyObject -from rest_framework.exceptions import ValidationError, NotFound from django.conf import settings from django.http import HttpRequest, HttpResponse +from django.utils.functional import SimpleLazyObject +from rest_framework.exceptions import NotFound, ValidationError def get_organization(request): diff --git a/cvat/apps/iam/migrations/0001_remove_business_group.py b/cvat/apps/iam/migrations/0001_remove_business_group.py index 2bf1a56b4065..a37ca5a9e3c9 100644 --- a/cvat/apps/iam/migrations/0001_remove_business_group.py +++ b/cvat/apps/iam/migrations/0001_remove_business_group.py @@ -2,7 +2,6 @@ from django.conf import settings from django.db import migrations - BUSINESS_GROUP_NAME = "business" USER_GROUP_NAME = "user" diff --git a/cvat/apps/iam/rules/tests/generate_tests.py b/cvat/apps/iam/rules/tests/generate_tests.py index 729de6732eb2..0bc788300982 100755 --- a/cvat/apps/iam/rules/tests/generate_tests.py +++ b/cvat/apps/iam/rules/tests/generate_tests.py @@ -10,8 +10,8 @@ from collections.abc import Sequence from concurrent.futures import ThreadPoolExecutor from functools import partial -from typing import Optional from pathlib import Path +from typing import Optional REPO_ROOT = Path(__file__).resolve().parents[5] diff --git a/cvat/apps/iam/schema.py b/cvat/apps/iam/schema.py index 46f9e31052c1..a09bac4817a1 100644 --- a/cvat/apps/iam/schema.py +++ b/cvat/apps/iam/schema.py @@ -9,7 +9,6 @@ from drf_spectacular.authentication import SessionScheme, TokenScheme from drf_spectacular.extensions import OpenApiAuthenticationExtension from drf_spectacular.openapi import AutoSchema - from rest_framework import serializers diff --git a/cvat/apps/iam/serializers.py b/cvat/apps/iam/serializers.py index 967b696a4f21..3ff5e01497c5 100644 --- a/cvat/apps/iam/serializers.py +++ b/cvat/apps/iam/serializers.py @@ -3,23 +3,21 @@ # # SPDX-License-Identifier: MIT -from dj_rest_auth.registration.serializers import RegisterSerializer -from dj_rest_auth.serializers import PasswordResetSerializer, LoginSerializer -from django.core.exceptions import ValidationError as DjangoValidationError -from rest_framework.exceptions import ValidationError -from rest_framework import serializers +from typing import Optional, Union + from allauth.account import app_settings as allauth_settings -from allauth.account.utils import filter_users_by_email from allauth.account.adapter import get_adapter -from allauth.account.utils import setup_user_email from allauth.account.models import EmailAddress - +from allauth.account.utils import filter_users_by_email, setup_user_email +from dj_rest_auth.registration.serializers import RegisterSerializer +from dj_rest_auth.serializers import LoginSerializer, PasswordResetSerializer from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.models import User - +from django.core.exceptions import ValidationError as DjangoValidationError from drf_spectacular.utils import extend_schema_field -from typing import Optional, Union +from rest_framework import serializers +from rest_framework.exceptions import ValidationError from cvat.apps.iam.forms import ResetPasswordFormEx from cvat.apps.iam.utils import get_dummy_user diff --git a/cvat/apps/iam/signals.py b/cvat/apps/iam/signals.py index 73f919a1a4a4..bce73de3dd92 100644 --- a/cvat/apps/iam/signals.py +++ b/cvat/apps/iam/signals.py @@ -3,8 +3,8 @@ # SPDX-License-Identifier: MIT from django.conf import settings -from django.contrib.auth.models import User, Group -from django.db.models.signals import post_save, post_migrate +from django.contrib.auth.models import Group, User +from django.db.models.signals import post_migrate, post_save def register_groups(sender, **kwargs): @@ -61,6 +61,7 @@ def register_signals(app_config): post_save.connect(create_user, sender=User) elif settings.IAM_TYPE == 'LDAP': import django_auth_ldap.backend + # Map groups from LDAP to roles, convert a user to super user if he/she # has an admin group. django_auth_ldap.backend.populate_user.connect(create_user) diff --git a/cvat/apps/iam/tests/test_rest_api.py b/cvat/apps/iam/tests/test_rest_api.py index d3de9fd6f1df..764e7eec87eb 100644 --- a/cvat/apps/iam/tests/test_rest_api.py +++ b/cvat/apps/iam/tests/test_rest_api.py @@ -3,18 +3,16 @@ # # SPDX-License-Identifier: MIT -from django.urls import reverse +from allauth.account.views import EmailVerificationSentView +from django.test import override_settings +from django.urls import path, re_path, reverse from rest_framework import status -from rest_framework.test import APITestCase, APIClient from rest_framework.authtoken.models import Token -from django.test import override_settings -from django.urls import path, re_path -from allauth.account.views import EmailVerificationSentView +from rest_framework.test import APIClient, APITestCase from cvat.apps.iam.urls import urlpatterns as iam_url_patterns from cvat.apps.iam.views import ConfirmEmailViewEx - urlpatterns = iam_url_patterns + [ re_path(r'^account-confirm-email/(?P[-:\w]+)/$', ConfirmEmailViewEx.as_view(), name='account_confirm_email'), diff --git a/cvat/apps/iam/urls.py b/cvat/apps/iam/urls.py index 8b8135fc2d9a..c0b1dcbe7d86 100644 --- a/cvat/apps/iam/urls.py +++ b/cvat/apps/iam/urls.py @@ -3,17 +3,23 @@ # # SPDX-License-Identifier: MIT -from django.urls import path, re_path +from allauth.account import app_settings as allauth_settings +from dj_rest_auth.views import ( + LogoutView, + PasswordChangeView, + PasswordResetConfirmView, + PasswordResetView, +) from django.conf import settings +from django.urls import path, re_path from django.urls.conf import include -from dj_rest_auth.views import ( - LogoutView, PasswordChangeView, - PasswordResetView, PasswordResetConfirmView) -from allauth.account import app_settings as allauth_settings from cvat.apps.iam.views import ( - SigningView, RegisterViewEx, RulesView, - ConfirmEmailViewEx, LoginViewEx + ConfirmEmailViewEx, + LoginViewEx, + RegisterViewEx, + RulesView, + SigningView, ) BASIC_LOGIN_PATH_NAME = 'rest_login' diff --git a/cvat/apps/iam/utils.py b/cvat/apps/iam/utils.py index 8095902769f3..5f5bee19352f 100644 --- a/cvat/apps/iam/utils.py +++ b/cvat/apps/iam/utils.py @@ -1,9 +1,9 @@ -from pathlib import Path import functools import hashlib import importlib import io import tarfile +from pathlib import Path from django.conf import settings from django.contrib.sessions.backends.base import SessionBase @@ -30,8 +30,8 @@ def add_opa_rules_path(path: Path) -> None: get_opa_bundle.cache_clear() def get_dummy_user(email): - from allauth.account.models import EmailAddress from allauth.account import app_settings + from allauth.account.models import EmailAddress from allauth.account.utils import filter_users_by_email users = filter_users_by_email(email) diff --git a/cvat/apps/iam/views.py b/cvat/apps/iam/views.py index 928d170c3bc4..b17be7ac7cb3 100644 --- a/cvat/apps/iam/views.py +++ b/cvat/apps/iam/views.py @@ -5,31 +5,34 @@ import functools -from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect -from rest_framework import views, serializers -from rest_framework.exceptions import ValidationError -from rest_framework.permissions import AllowAny -from django.conf import settings -from django.http import HttpResponse -from django.views.decorators.http import etag as django_etag -from rest_framework.response import Response +from allauth.account import app_settings as allauth_settings +from allauth.account.utils import complete_signup, has_verified_email, send_email_confirmation +from allauth.account.views import ConfirmEmailView from dj_rest_auth.app_settings import api_settings as dj_rest_auth_settings from dj_rest_auth.registration.views import RegisterView from dj_rest_auth.utils import jwt_encode from dj_rest_auth.views import LoginView -from allauth.account import app_settings as allauth_settings -from allauth.account.views import ConfirmEmailView -from allauth.account.utils import complete_signup, has_verified_email, send_email_confirmation - -from furl import furl - -from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer, extend_schema_view +from django.conf import settings +from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect +from django.views.decorators.http import etag as django_etag from drf_spectacular.contrib.rest_auth import get_token_serializer_class +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import ( + OpenApiResponse, + extend_schema, + extend_schema_view, + inline_serializer, +) +from furl import furl +from rest_framework import serializers, views +from rest_framework.exceptions import ValidationError +from rest_framework.permissions import AllowAny +from rest_framework.response import Response from .authentication import Signer from .utils import get_opa_bundle + @extend_schema(tags=['auth']) @extend_schema_view(post=extend_schema( summary='This method signs URL for access to the server', diff --git a/cvat/apps/lambda_manager/models.py b/cvat/apps/lambda_manager/models.py index 47d732c41dd1..f6e684a1cc0f 100644 --- a/cvat/apps/lambda_manager/models.py +++ b/cvat/apps/lambda_manager/models.py @@ -5,6 +5,7 @@ import django.db.models as models + class FunctionKind(models.TextChoices): DETECTOR = "detector" INTERACTOR = "interactor" diff --git a/cvat/apps/lambda_manager/permissions.py b/cvat/apps/lambda_manager/permissions.py index 94800f0edd5d..06a43253fa3c 100644 --- a/cvat/apps/lambda_manager/permissions.py +++ b/cvat/apps/lambda_manager/permissions.py @@ -8,6 +8,7 @@ from cvat.apps.engine.permissions import JobPermission, TaskPermission from cvat.apps.iam.permissions import OpenPolicyAgentPermission, StrEnum + class LambdaPermission(OpenPolicyAgentPermission): class Scopes(StrEnum): LIST = 'list' diff --git a/cvat/apps/lambda_manager/serializers.py b/cvat/apps/lambda_manager/serializers.py index ab8809bd7cc8..96ffffa5702c 100644 --- a/cvat/apps/lambda_manager/serializers.py +++ b/cvat/apps/lambda_manager/serializers.py @@ -5,6 +5,7 @@ from drf_spectacular.utils import extend_schema_serializer from rest_framework import serializers + class SublabelMappingEntrySerializer(serializers.Serializer): name = serializers.CharField() attributes = serializers.DictField(child=serializers.CharField(), required=False) diff --git a/cvat/apps/lambda_manager/tests/test_lambda.py b/cvat/apps/lambda_manager/tests/test_lambda.py index f9292b278b45..3e5cc43f8743 100644 --- a/cvat/apps/lambda_manager/tests/test_lambda.py +++ b/cvat/apps/lambda_manager/tests/test_lambda.py @@ -3,12 +3,12 @@ # # SPDX-License-Identifier: MIT +import json +import os from collections import Counter, OrderedDict from itertools import groupby from typing import Optional from unittest import mock, skip -import json -import os import requests from django.contrib.auth.models import Group, User @@ -16,7 +16,11 @@ from rest_framework import status from cvat.apps.engine.tests.utils import ( - ApiTestBase, filter_dict, ForceLogin, generate_image_file, get_paginated_collection + ApiTestBase, + ForceLogin, + filter_dict, + generate_image_file, + get_paginated_collection, ) LAMBDA_ROOT_PATH = '/api/lambda' diff --git a/cvat/apps/lambda_manager/views.py b/cvat/apps/lambda_manager/views.py index 559ef29813b5..3df3476bf083 100644 --- a/cvat/apps/lambda_manager/views.py +++ b/cvat/apps/lambda_manager/views.py @@ -19,34 +19,45 @@ import numpy as np import requests import rq -from cvat.apps.events.handlers import handle_function_call -from cvat.apps.lambda_manager.signals import interactive_function_call_signal from django.conf import settings from django.core.exceptions import ObjectDoesNotExist, ValidationError from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import (OpenApiParameter, OpenApiResponse, - extend_schema, extend_schema_view, - inline_serializer) +from drf_spectacular.utils import ( + OpenApiParameter, + OpenApiResponse, + extend_schema, + extend_schema_view, + inline_serializer, +) from rest_framework import serializers, status, viewsets -from rest_framework.response import Response from rest_framework.request import Request +from rest_framework.response import Response import cvat.apps.dataset_manager as dm from cvat.apps.engine.frame_provider import TaskFrameProvider +from cvat.apps.engine.log import ServerLogManager from cvat.apps.engine.models import ( - Job, ShapeType, SourceType, Task, Label, RequestAction, RequestTarget + Job, + Label, + RequestAction, + RequestTarget, + ShapeType, + SourceType, + Task, ) from cvat.apps.engine.rq_job_handler import RQId, RQJobMetaField from cvat.apps.engine.serializers import LabeledDataSerializer +from cvat.apps.engine.utils import define_dependent_job, get_rq_job_meta, get_rq_lock_by_user +from cvat.apps.events.handlers import handle_function_call +from cvat.apps.iam.filters import ORGANIZATION_OPEN_API_PARAMETERS from cvat.apps.lambda_manager.models import FunctionKind from cvat.apps.lambda_manager.permissions import LambdaPermission from cvat.apps.lambda_manager.serializers import ( - FunctionCallRequestSerializer, FunctionCallSerializer + FunctionCallRequestSerializer, + FunctionCallSerializer, ) -from cvat.apps.engine.log import ServerLogManager -from cvat.apps.engine.utils import define_dependent_job, get_rq_job_meta, get_rq_lock_by_user +from cvat.apps.lambda_manager.signals import interactive_function_call_signal from cvat.utils.http import make_requests_session -from cvat.apps.iam.filters import ORGANIZATION_OPEN_API_PARAMETERS slogger = ServerLogManager(__name__) diff --git a/cvat/apps/log_viewer/views.py b/cvat/apps/log_viewer/views.py index 9e52f546c634..0b20327f4658 100644 --- a/cvat/apps/log_viewer/views.py +++ b/cvat/apps/log_viewer/views.py @@ -4,11 +4,11 @@ from django.conf import settings from django.http import HttpResponsePermanentRedirect +from drf_spectacular.utils import extend_schema from rest_framework import status, viewsets from rest_framework.decorators import action from rest_framework.response import Response -from drf_spectacular.utils import extend_schema @extend_schema(exclude=True) class LogViewerAccessViewSet(viewsets.ViewSet): diff --git a/cvat/apps/organizations/admin.py b/cvat/apps/organizations/admin.py index 756100244743..ca19407670f4 100644 --- a/cvat/apps/organizations/admin.py +++ b/cvat/apps/organizations/admin.py @@ -2,9 +2,11 @@ # # SPDX-License-Identifier: MIT -from .models import Organization, Membership from django.contrib import admin +from .models import Membership, Organization + + class MembershipInline(admin.TabularInline): model = Membership extra = 0 diff --git a/cvat/apps/organizations/apps.py b/cvat/apps/organizations/apps.py index f73094af1723..518a646b3e94 100644 --- a/cvat/apps/organizations/apps.py +++ b/cvat/apps/organizations/apps.py @@ -5,6 +5,7 @@ from django.apps import AppConfig + class OrganizationsConfig(AppConfig): name = 'cvat.apps.organizations' diff --git a/cvat/apps/organizations/migrations/0001_initial.py b/cvat/apps/organizations/migrations/0001_initial.py index 1d2689d343d1..cc2ecb76cf5e 100644 --- a/cvat/apps/organizations/migrations/0001_initial.py +++ b/cvat/apps/organizations/migrations/0001_initial.py @@ -1,8 +1,8 @@ # Generated by Django 3.2.8 on 2021-10-26 14:52 +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): diff --git a/cvat/apps/organizations/models.py b/cvat/apps/organizations/models.py index 3da77bafbebf..530f79b1ada3 100644 --- a/cvat/apps/organizations/models.py +++ b/cvat/apps/organizations/models.py @@ -4,19 +4,20 @@ # SPDX-License-Identifier: MIT from datetime import timedelta -from django.conf import settings -from allauth.account.adapter import get_adapter -from django.contrib.sites.shortcuts import get_current_site -from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import extend_schema_field -from django.db import models +from allauth.account.adapter import get_adapter +from django.conf import settings from django.contrib.auth import get_user_model +from django.contrib.sites.shortcuts import get_current_site from django.core.exceptions import ImproperlyConfigured +from django.db import models from django.utils import timezone +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field from cvat.apps.engine.models import TimestampedModel + class Organization(TimestampedModel): slug = models.SlugField(max_length=16, blank=False, unique=True) name = models.CharField(max_length=64, blank=True) diff --git a/cvat/apps/organizations/permissions.py b/cvat/apps/organizations/permissions.py index e45b05d978c3..8eaa9f074f63 100644 --- a/cvat/apps/organizations/permissions.py +++ b/cvat/apps/organizations/permissions.py @@ -9,6 +9,7 @@ from .models import Membership + class OrganizationPermission(OpenPolicyAgentPermission): class Scopes(StrEnum): LIST = 'list' diff --git a/cvat/apps/organizations/serializers.py b/cvat/apps/organizations/serializers.py index 9cfb467aa3b9..3e6a712473f9 100644 --- a/cvat/apps/organizations/serializers.py +++ b/cvat/apps/organizations/serializers.py @@ -3,19 +3,21 @@ # # SPDX-License-Identifier: MIT -from attr.converters import to_bool -from django.contrib.auth import get_user_model from allauth.account.models import EmailAddress -from django.core.exceptions import ObjectDoesNotExist +from attr.converters import to_bool from django.conf import settings +from django.contrib.auth import get_user_model from django.contrib.auth.models import User +from django.core.exceptions import ObjectDoesNotExist from django.db import transaction - from rest_framework import serializers + from cvat.apps.engine.serializers import BasicUserSerializer from cvat.apps.iam.utils import get_dummy_user + from .models import Invitation, Membership, Organization + class OrganizationReadSerializer(serializers.ModelSerializer): owner = BasicUserSerializer(allow_null=True) class Meta: diff --git a/cvat/apps/organizations/throttle.py b/cvat/apps/organizations/throttle.py index 438538b61d4a..c99db364030c 100644 --- a/cvat/apps/organizations/throttle.py +++ b/cvat/apps/organizations/throttle.py @@ -4,5 +4,6 @@ from rest_framework.throttling import UserRateThrottle + class ResendOrganizationInvitationThrottle(UserRateThrottle): rate = '5/hour' diff --git a/cvat/apps/organizations/urls.py b/cvat/apps/organizations/urls.py index 068f72b0968d..1e68d0299cc3 100644 --- a/cvat/apps/organizations/urls.py +++ b/cvat/apps/organizations/urls.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: MIT from rest_framework.routers import DefaultRouter + from .views import InvitationViewSet, MembershipViewSet, OrganizationViewSet router = DefaultRouter(trailing_slash=False) diff --git a/cvat/apps/organizations/views.py b/cvat/apps/organizations/views.py index 11b92b29cad8..f628bff3e1d9 100644 --- a/cvat/apps/organizations/views.py +++ b/cvat/apps/organizations/views.py @@ -3,30 +3,35 @@ # # SPDX-License-Identifier: MIT -from django.utils.crypto import get_random_string -from django.db import transaction from django.core.exceptions import ImproperlyConfigured - -from rest_framework import mixins, viewsets, status -from rest_framework.permissions import SAFE_METHODS +from django.db import transaction +from django.utils.crypto import get_random_string +from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view +from rest_framework import mixins, status, viewsets from rest_framework.decorators import action +from rest_framework.permissions import SAFE_METHODS from rest_framework.response import Response -from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view - +from cvat.apps.engine.mixins import PartialUpdateModelMixin from cvat.apps.iam.filters import ORGANIZATION_OPEN_API_PARAMETERS from cvat.apps.organizations.permissions import ( - InvitationPermission, MembershipPermission, OrganizationPermission) + InvitationPermission, + MembershipPermission, + OrganizationPermission, +) from cvat.apps.organizations.throttle import ResendOrganizationInvitationThrottle -from cvat.apps.engine.mixins import PartialUpdateModelMixin from .models import Invitation, Membership, Organization - from .serializers import ( - InvitationReadSerializer, InvitationWriteSerializer, - MembershipReadSerializer, MembershipWriteSerializer, - OrganizationReadSerializer, OrganizationWriteSerializer, - AcceptInvitationReadSerializer) + AcceptInvitationReadSerializer, + InvitationReadSerializer, + InvitationWriteSerializer, + MembershipReadSerializer, + MembershipWriteSerializer, + OrganizationReadSerializer, + OrganizationWriteSerializer, +) + @extend_schema(tags=['organizations']) @extend_schema_view( diff --git a/cvat/apps/webhooks/apps.py b/cvat/apps/webhooks/apps.py index ac193baed755..50ba88aa6278 100644 --- a/cvat/apps/webhooks/apps.py +++ b/cvat/apps/webhooks/apps.py @@ -9,7 +9,7 @@ class WebhooksConfig(AppConfig): name = "cvat.apps.webhooks" def ready(self): - from . import signals # pylint: disable=unused-import - from cvat.apps.iam.permissions import load_app_permissions load_app_permissions(self) + + from . import signals # pylint: disable=unused-import diff --git a/cvat/apps/webhooks/migrations/0001_initial.py b/cvat/apps/webhooks/migrations/0001_initial.py index fe8f296b0514..49a8af2f582c 100644 --- a/cvat/apps/webhooks/migrations/0001_initial.py +++ b/cvat/apps/webhooks/migrations/0001_initial.py @@ -1,9 +1,10 @@ # Generated by Django 3.2.15 on 2022-09-19 08:26 -import cvat.apps.webhooks.models +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion + +import cvat.apps.webhooks.models class Migration(migrations.Migration): diff --git a/cvat/apps/webhooks/permissions.py b/cvat/apps/webhooks/permissions.py index e5d132c55de6..7fe0904c7ed2 100644 --- a/cvat/apps/webhooks/permissions.py +++ b/cvat/apps/webhooks/permissions.py @@ -4,7 +4,6 @@ # SPDX-License-Identifier: MIT from django.conf import settings - from rest_framework.exceptions import ValidationError from cvat.apps.engine.models import Project @@ -13,6 +12,7 @@ from .models import WebhookTypeChoice + class WebhookPermission(OpenPolicyAgentPermission): class Scopes(StrEnum): CREATE = 'create' diff --git a/cvat/apps/webhooks/serializers.py b/cvat/apps/webhooks/serializers.py index d2bb1f309105..b3060986eefd 100644 --- a/cvat/apps/webhooks/serializers.py +++ b/cvat/apps/webhooks/serializers.py @@ -7,13 +7,8 @@ from cvat.apps.engine.models import Project from cvat.apps.engine.serializers import BasicUserSerializer, WriteOnceMixin -from .event_type import EventTypeChoice, ProjectEvents, OrganizationEvents -from .models import ( - Webhook, - WebhookContentTypeChoice, - WebhookTypeChoice, - WebhookDelivery, -) +from .event_type import EventTypeChoice, OrganizationEvents, ProjectEvents +from .models import Webhook, WebhookContentTypeChoice, WebhookDelivery, WebhookTypeChoice class EventTypeValidator: diff --git a/cvat/apps/webhooks/signals.py b/cvat/apps/webhooks/signals.py index 3e17e8f3d8f6..eb14edcdb2c3 100644 --- a/cvat/apps/webhooks/signals.py +++ b/cvat/apps/webhooks/signals.py @@ -13,17 +13,21 @@ from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from django.db import transaction -from django.db.models.signals import (post_delete, post_save, pre_delete, - pre_save) +from django.db.models.signals import post_delete, post_save, pre_delete, pre_save from django.dispatch import Signal, receiver from cvat.apps.engine.models import Comment, Issue, Job, Project, Task from cvat.apps.engine.serializers import BasicUserSerializer -from cvat.apps.events.handlers import (get_request, get_serializer, get_user, - get_instance_diff, organization_id, - project_id) +from cvat.apps.events.handlers import ( + get_instance_diff, + get_request, + get_serializer, + get_user, + organization_id, + project_id, +) from cvat.apps.organizations.models import Invitation, Membership, Organization -from cvat.utils.http import make_requests_session, PROXIES_FOR_UNTRUSTED_URLS +from cvat.utils.http import PROXIES_FOR_UNTRUSTED_URLS, make_requests_session from .event_type import EventTypeChoice, event_name from .models import Webhook, WebhookDelivery, WebhookTypeChoice diff --git a/cvat/apps/webhooks/urls.py b/cvat/apps/webhooks/urls.py index c309df746f96..26f86fc2313e 100644 --- a/cvat/apps/webhooks/urls.py +++ b/cvat/apps/webhooks/urls.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: MIT from rest_framework.routers import DefaultRouter + from .views import WebhookViewSet router = DefaultRouter(trailing_slash=False) diff --git a/cvat/apps/webhooks/views.py b/cvat/apps/webhooks/views.py index 66529bc6a7bd..4c084b3f3541 100644 --- a/cvat/apps/webhooks/views.py +++ b/cvat/apps/webhooks/views.py @@ -2,9 +2,13 @@ # # SPDX-License-Identifier: MIT -from drf_spectacular.utils import (OpenApiParameter, OpenApiResponse, - OpenApiTypes, extend_schema, - extend_schema_view) +from drf_spectacular.utils import ( + OpenApiParameter, + OpenApiResponse, + OpenApiTypes, + extend_schema, + extend_schema_view, +) from rest_framework import status, viewsets from rest_framework.decorators import action from rest_framework.permissions import SAFE_METHODS @@ -16,8 +20,12 @@ from .event_type import AllEvents, OrganizationEvents, ProjectEvents from .models import Webhook, WebhookDelivery, WebhookTypeChoice from .permissions import WebhookPermission -from .serializers import (EventsSerializer, WebhookDeliveryReadSerializer, - WebhookReadSerializer, WebhookWriteSerializer) +from .serializers import ( + EventsSerializer, + WebhookDeliveryReadSerializer, + WebhookReadSerializer, + WebhookWriteSerializer, +) from .signals import signal_ping, signal_redelivery diff --git a/cvat/settings/base.py b/cvat/settings/base.py index 0f6147dc4bf0..c73cb31eafa2 100644 --- a/cvat/settings/base.py +++ b/cvat/settings/base.py @@ -19,9 +19,9 @@ import os import sys import tempfile +import urllib from datetime import timedelta from enum import Enum -import urllib from attr.converters import to_bool from corsheaders.defaults import default_headers @@ -74,7 +74,7 @@ def generate_secret_key(): try: sys.path.append(BASE_DIR) - from keys.secret_key import SECRET_KEY # pylint: disable=unused-import + from keys.secret_key import SECRET_KEY # pylint: disable=unused-import except ModuleNotFoundError: generate_secret_key() from keys.secret_key import SECRET_KEY @@ -740,6 +740,7 @@ class CVAT_QUEUES(Enum): CVAT_CONCURRENT_CHUNK_PROCESSING = int(os.getenv('CVAT_CONCURRENT_CHUNK_PROCESSING', 1)) from cvat.rq_patching import update_started_job_registry_cleanup + update_started_job_registry_cleanup() CLOUD_DATA_DOWNLOADING_MAX_THREADS_NUMBER = 4 diff --git a/cvat/settings/email_settings.py b/cvat/settings/email_settings.py index d3f9621e09d4..f83f918339de 100644 --- a/cvat/settings/email_settings.py +++ b/cvat/settings/email_settings.py @@ -5,7 +5,6 @@ from cvat.settings.production import * - # https://github.com/pennersr/django-allauth ACCOUNT_AUTHENTICATION_METHOD = 'username_email' ACCOUNT_CONFIRM_EMAIL_ON_GET = True diff --git a/cvat/settings/testing.py b/cvat/settings/testing.py index 3cd47559fbd0..e0391e4c3b40 100644 --- a/cvat/settings/testing.py +++ b/cvat/settings/testing.py @@ -2,9 +2,10 @@ # # SPDX-License-Identifier: MIT -from .development import * import tempfile +from .development import * + DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', @@ -73,11 +74,13 @@ TEST_RUNNER = "cvat.settings.testing.PatchedDiscoverRunner" from django.test.runner import DiscoverRunner + + class PatchedDiscoverRunner(DiscoverRunner): def __init__(self, *args, **kwargs): # Used fakeredis for testing (don't affect production redis) - from fakeredis import FakeRedis, FakeStrictRedis import django_rq.queues + from fakeredis import FakeRedis, FakeStrictRedis simple_redis = FakeRedis() strict_redis = FakeStrictRedis() django_rq.queues.get_redis_connection = lambda _, strict: strict_redis \ diff --git a/cvat/urls.py b/cvat/urls.py index 08257a14b811..ca62b7cb03a3 100644 --- a/cvat/urls.py +++ b/cvat/urls.py @@ -20,7 +20,7 @@ from django.apps import apps from django.contrib import admin -from django.urls import path, include +from django.urls import include, path urlpatterns = [ path("admin/", admin.site.urls), diff --git a/cvat/utils/http.py b/cvat/utils/http.py index 2cb1b7498b32..ab8771aaa2ae 100644 --- a/cvat/utils/http.py +++ b/cvat/utils/http.py @@ -2,10 +2,9 @@ # # SPDX-License-Identifier: MIT -from django.conf import settings - import requests import requests.utils +from django.conf import settings from cvat import __version__ diff --git a/dev/update_version.py b/dev/update_version.py index fbe5da9971c0..7419a581ef4c 100755 --- a/dev/update_version.py +++ b/dev/update_version.py @@ -9,7 +9,6 @@ from re import Match, Pattern from typing import Callable - SUCCESS_CHAR = "\u2714" FAIL_CHAR = "\u2716" diff --git a/pyproject.toml b/pyproject.toml index 528bdc579fcc..9447beefa868 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,6 +3,14 @@ profile = "black" forced_separate = ["tests"] line_length = 100 skip_gitignore = true # align tool behavior with Black +extend_skip=[ + # Correctly ordering the imports in serverless functions would + # require a pyproject.toml in every function; don't bother with it for now. + "serverless", + # Sorting the imports in this file causes test failures; + # TODO: fix them and remove this ignore. + "cvat/apps/dataset_manager/formats/registry.py", +] [tool.black] line-length = 100 diff --git a/rqscheduler.py b/rqscheduler.py index 5ae76e64a7f0..b6cebe80f285 100644 --- a/rqscheduler.py +++ b/rqscheduler.py @@ -4,10 +4,10 @@ # implementation. This is required for correct work with CVAT queue settings and # their access options such as login and password. +from rq_scheduler.scripts import rqscheduler + # Required to initialize Django settings correctly from cvat.asgi import application # pylint: disable=unused-import -from rq_scheduler.scripts import rqscheduler - if __name__ == "__main__": rqscheduler.main() diff --git a/tests/python/pyproject.toml b/tests/python/pyproject.toml index ab4db6695977..6b5fba136a78 100644 --- a/tests/python/pyproject.toml +++ b/tests/python/pyproject.toml @@ -3,3 +3,4 @@ profile = "black" forced_separate = ["tests"] line_length = 100 skip_gitignore = true # align tool behavior with Black +known_first_party = ["shared", "rest_api", "sdk", "cli"] diff --git a/utils/dataset_manifest/__init__.py b/utils/dataset_manifest/__init__.py index 74fd25ede729..7efcfcb48406 100644 --- a/utils/dataset_manifest/__init__.py +++ b/utils/dataset_manifest/__init__.py @@ -1,4 +1,4 @@ # Copyright (C) 2021-2022 Intel Corporation # # SPDX-License-Identifier: MIT -from .core import VideoManifestManager, ImageManifestManager, is_manifest +from .core import ImageManifestManager, VideoManifestManager, is_manifest diff --git a/utils/dataset_manifest/core.py b/utils/dataset_manifest/core.py index 449e70d64098..a855d170e86b 100644 --- a/utils/dataset_manifest/core.py +++ b/utils/dataset_manifest/core.py @@ -3,25 +3,24 @@ # # SPDX-License-Identifier: MIT -from enum import Enum -from io import StringIO -import av import json import os - from abc import ABC, abstractmethod from collections.abc import Iterator from contextlib import closing +from enum import Enum +from inspect import isgenerator +from io import StringIO from itertools import islice -from PIL import Image from json.decoder import JSONDecodeError -from inspect import isgenerator +from typing import Any, Callable, Optional, Union + +import av +from PIL import Image from .errors import InvalidManifestError, InvalidVideoError -from .utils import SortingMethod, md5_hash, rotate_image, sort from .types import NamedBytesIO - -from typing import Any, Union, Optional, Callable +from .utils import SortingMethod, md5_hash, rotate_image, sort class VideoStreamReader: diff --git a/utils/dataset_manifest/create.py b/utils/dataset_manifest/create.py index 64efaed60f2d..fa31300e058a 100755 --- a/utils/dataset_manifest/create.py +++ b/utils/dataset_manifest/create.py @@ -7,13 +7,14 @@ import argparse import os -import sys import re +import sys from glob import glob from tqdm import tqdm -from utils import detect_related_images, is_image, is_video, SortingMethod +from utils import SortingMethod, detect_related_images, is_image, is_video + def get_args(): parser = argparse.ArgumentParser() @@ -98,5 +99,5 @@ def main(): if __name__ == "__main__": base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.append(base_dir) - from dataset_manifest.core import VideoManifestManager, ImageManifestManager + from dataset_manifest.core import ImageManifestManager, VideoManifestManager main() diff --git a/utils/dataset_manifest/types.py b/utils/dataset_manifest/types.py index 8847eee457ba..5ddcce9ad5c9 100644 --- a/utils/dataset_manifest/types.py +++ b/utils/dataset_manifest/types.py @@ -5,6 +5,7 @@ from io import BytesIO from typing import Protocol + class Named(Protocol): filename: str diff --git a/utils/dataset_manifest/utils.py b/utils/dataset_manifest/utils.py index b4eee9686b71..9cb89ce5cd4d 100644 --- a/utils/dataset_manifest/utils.py +++ b/utils/dataset_manifest/utils.py @@ -2,15 +2,17 @@ # # SPDX-License-Identifier: MIT -import os -import re import hashlib import mimetypes +import os +import re +from enum import Enum +from random import shuffle + import cv2 as cv from av import VideoFrame -from enum import Enum from natsort import os_sorted -from random import shuffle + def rotate_image(image, angle): height, width = image.shape[:2] diff --git a/utils/dicom_converter/script.py b/utils/dicom_converter/script.py index 3fe7ef0be6dd..a201845965f3 100644 --- a/utils/dicom_converter/script.py +++ b/utils/dicom_converter/script.py @@ -3,17 +3,16 @@ # SPDX-License-Identifier: MIT -import os import argparse import logging +import os from glob import glob import numpy as np -from tqdm import tqdm from PIL import Image from pydicom import dcmread from pydicom.pixel_data_handlers.util import convert_color_space - +from tqdm import tqdm # Script configuration logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(message)s") From 30fbfb2ff7a5e523d36358d3bcbe2db8f43360ff Mon Sep 17 00:00:00 2001 From: Maria Khrustaleva Date: Mon, 6 Jan 2025 15:13:28 +0100 Subject: [PATCH 19/31] Allow empty items to be cached (#8890) ### Motivation and context Details are described [here](https://github.com/cvat-ai/cvat_enterprise/pull/252). ### How has this been tested? ### Checklist - [ ] I submit my changes into the `develop` branch - [ ] I have created a changelog fragment - [ ] I have updated the documentation accordingly - [ ] I have added tests to cover my changes - [ ] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) - [ ] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/cvat-ai/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/cvat-ai/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/cvat-ai/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/cvat-ai/cvat/tree/develop/cvat-ui#versioning)) ### License - [ ] I submit _my code changes_ under the same [MIT License]( https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. ## Summary by CodeRabbit - **Bug Fixes** - Improved cache error handling by introducing a specific exception for empty cache scenarios - Enhanced data validation when creating cache items to prevent caching of invalid or empty data --- cvat/apps/engine/cache.py | 47 +++++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py index 43c2be7bc57e..ffe8fe0cb920 100644 --- a/cvat/apps/engine/cache.py +++ b/cvat/apps/engine/cache.py @@ -218,17 +218,19 @@ def _create_and_set_cache_item( item_data = create_callback() item_data_bytes = item_data[0].getvalue() item = (item_data[0], item_data[1], cls._get_checksum(item_data_bytes), timestamp) - if item_data_bytes: - cache = cls._cache() - with get_rq_lock_for_job( - cls._get_queue(), - key, - ): - cached_item = cache.get(key) - if cached_item is not None and timestamp <= cached_item[3]: - item = cached_item - else: - cache.set(key, item, timeout=cache_item_ttl or cache.default_timeout) + + # allow empty data to be set in cache to prevent + # future rq jobs from being enqueued to prepare the item + cache = cls._cache() + with get_rq_lock_for_job( + cls._get_queue(), + key, + ): + cached_item = cache.get(key) + if cached_item is not None and timestamp <= cached_item[3]: + item = cached_item + else: + cache.set(key, item, timeout=cache_item_ttl or cache.default_timeout) return item @@ -353,11 +355,18 @@ def _make_frame_context_images_chunk_key(self, db_data: models.Data, frame_numbe def _to_data_with_mime(self, cache_item: _CacheItem) -> DataWithMime: ... @overload - def _to_data_with_mime(self, cache_item: Optional[_CacheItem]) -> Optional[DataWithMime]: ... + def _to_data_with_mime( + self, cache_item: Optional[_CacheItem], *, allow_none: bool = False + ) -> Optional[DataWithMime]: ... - def _to_data_with_mime(self, cache_item: Optional[_CacheItem]) -> Optional[DataWithMime]: + def _to_data_with_mime( + self, cache_item: Optional[_CacheItem], *, allow_none: bool = False + ) -> Optional[DataWithMime]: if not cache_item: - return None + if allow_none: + return None + + raise ValueError("A cache item is not allowed to be None") return cache_item[:2] @@ -385,7 +394,8 @@ def get_task_chunk( return self._to_data_with_mime( self._get_cache_item( key=self._make_chunk_key(db_task, chunk_number, quality=quality), - ) + ), + allow_none=True, ) def get_or_set_task_chunk( @@ -413,7 +423,8 @@ def get_segment_task_chunk( return self._to_data_with_mime( self._get_cache_item( key=self._make_segment_task_chunk_key(db_segment, chunk_number, quality=quality), - ) + ), + allow_none=True, ) def get_or_set_segment_task_chunk( @@ -510,7 +521,9 @@ def remove_context_images_chunks(self, params: Sequence[dict[str, Any]]) -> None self._bulk_delete_cache_items(keys_to_remove) def get_cloud_preview(self, db_storage: models.CloudStorage) -> Optional[DataWithMime]: - return self._to_data_with_mime(self._get_cache_item(self._make_preview_key(db_storage))) + return self._to_data_with_mime( + self._get_cache_item(self._make_preview_key(db_storage)), allow_none=True + ) def get_or_set_cloud_preview(self, db_storage: models.CloudStorage) -> DataWithMime: return self._to_data_with_mime( From 25245e63d9bbdf0633f8b7d8259216f9f38fcaf2 Mon Sep 17 00:00:00 2001 From: Dmitrii Lavrukhin Date: Mon, 6 Jan 2025 18:29:36 +0400 Subject: [PATCH 20/31] correctly process export to yolo formats if both Train and default dataset are present (#8884) depends on https://github.com/cvat-ai/datumaro/pull/71 --- cvat/requirements/base.in | 2 +- cvat/requirements/base.txt | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cvat/requirements/base.in b/cvat/requirements/base.in index b3900f010dda..b0d1a8f82e0b 100644 --- a/cvat/requirements/base.in +++ b/cvat/requirements/base.in @@ -12,7 +12,7 @@ azure-storage-blob==12.13.0 boto3==1.17.61 clickhouse-connect==0.6.8 coreapi==2.3.3 -datumaro @ git+https://github.com/cvat-ai/datumaro.git@232c175ef1f3b7e55bd5162353df9c86a8116fde +datumaro @ git+https://github.com/cvat-ai/datumaro.git@02507a9d41413327ca2d67a4349e5553ce763a7c dj-pagination==2.5.0 # Despite direct indication allauth in requirements we should keep 'with_social' for dj-rest-auth # to avoid possible further versions conflicts (we use registration functionality) diff --git a/cvat/requirements/base.txt b/cvat/requirements/base.txt index fce784b9481c..45db95819864 100644 --- a/cvat/requirements/base.txt +++ b/cvat/requirements/base.txt @@ -1,4 +1,4 @@ -# SHA1:5a3efd0a5c1892698d4394f019ef659275b10fdb +# SHA1:8d2c3b645ed1f9efb7ca325d5da7eb9e07aced87 # # This file is autogenerated by pip-compile-multi # To update, run: @@ -56,7 +56,7 @@ cryptography==44.0.0 # pyjwt cycler==0.12.1 # via matplotlib -datumaro @ git+https://github.com/cvat-ai/datumaro.git@232c175ef1f3b7e55bd5162353df9c86a8116fde +datumaro @ git+https://github.com/cvat-ai/datumaro.git@02507a9d41413327ca2d67a4349e5553ce763a7c # via -r cvat/requirements/base.in defusedxml==0.7.1 # via @@ -197,7 +197,7 @@ oauthlib==3.2.2 # via requests-oauthlib orderedmultidict==1.0.1 # via furl -orjson==3.10.12 +orjson==3.10.13 # via datumaro packaging==24.2 # via @@ -308,7 +308,7 @@ rq-scheduler==0.13.1 # via -r cvat/requirements/base.in rsa==4.9 # via google-auth -ruamel-yaml==0.18.6 +ruamel-yaml==0.18.7 # via datumaro ruamel-yaml-clib==0.2.12 # via ruamel-yaml From 1124b29b851e6dee8ee0484a72b5c7237a67e39d Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Mon, 6 Jan 2025 20:51:08 +0300 Subject: [PATCH 21/31] Optimize honeypot selection algorithm (#8857) ### Motivation and context Improved performance of the honeypot selection/update algorithm, noticeable on tasks with ~50k frames. ### How has this been tested? ### Checklist - [ ] I submit my changes into the `develop` branch - [ ] I have created a changelog fragment - [ ] I have updated the documentation accordingly - [ ] I have added tests to cover my changes - [ ] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) - [ ] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/cvat-ai/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/cvat-ai/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/cvat-ai/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/cvat-ai/cvat/tree/develop/cvat-ui#versioning)) ### License - [ ] I submit _my code changes_ under the same [MIT License]( https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. ## Summary by CodeRabbit - **New Features** - Introduced a new attribute for improved frame selection in job validation. - Added a new class for managing item counts, enhancing frame selection logic. - **Bug Fixes** - Adjusted frame selection logic for better efficiency and accuracy. - **Documentation** - Updated comments and documentation to reflect new logic and attributes. --- cvat/apps/engine/serializers.py | 13 +-- cvat/apps/engine/task_validation.py | 121 ++++++++++++++++++++++------ 2 files changed, 104 insertions(+), 30 deletions(-) diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 395bf59108b9..6c760b42ba65 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -1111,7 +1111,7 @@ def _to_abs_frame(rel_frame: int) -> int: ) if bulk_context: - active_validation_frame_counts = bulk_context.active_validation_frame_counts + frame_selector = bulk_context.honeypot_frame_selector else: active_validation_frame_counts = { validation_frame: 0 for validation_frame in task_active_validation_frames @@ -1121,7 +1121,8 @@ def _to_abs_frame(rel_frame: int) -> int: if real_frame in task_active_validation_frames: active_validation_frame_counts[real_frame] += 1 - frame_selector = HoneypotFrameSelector(active_validation_frame_counts) + frame_selector = HoneypotFrameSelector(active_validation_frame_counts) + requested_frames = frame_selector.select_next_frames(segment_honeypots_count) requested_frames = list(map(_to_abs_frame, requested_frames)) else: @@ -1368,7 +1369,7 @@ def __init__( honeypot_frames: list[int], all_validation_frames: list[int], active_validation_frames: list[int], - validation_frame_counts: dict[int, int] | None = None + honeypot_frame_selector: HoneypotFrameSelector | None = None ): self.updated_honeypots: dict[int, models.Image] = {} self.updated_segments: list[int] = [] @@ -1380,7 +1381,7 @@ def __init__( self.honeypot_frames = honeypot_frames self.all_validation_frames = all_validation_frames self.active_validation_frames = active_validation_frames - self.active_validation_frame_counts = validation_frame_counts + self.honeypot_frame_selector = honeypot_frame_selector class TaskValidationLayoutWriteSerializer(serializers.Serializer): disabled_frames = serializers.ListField( @@ -1495,7 +1496,9 @@ def update(self, instance: models.Task, validated_data: dict[str, Any]) -> model ) elif frame_selection_method == models.JobFrameSelectionMethod.RANDOM_UNIFORM: # Reset distribution for active validation frames - bulk_context.active_validation_frame_counts = { f: 0 for f in active_validation_frames } + active_validation_frame_counts = { f: 0 for f in active_validation_frames } + frame_selector = HoneypotFrameSelector(active_validation_frame_counts) + bulk_context.honeypot_frame_selector = frame_selector # Could be done using Django ORM, but using order_by() and filter() # would result in an extra DB request diff --git a/cvat/apps/engine/task_validation.py b/cvat/apps/engine/task_validation.py index fe76b4e99408..4734c153e8b4 100644 --- a/cvat/apps/engine/task_validation.py +++ b/cvat/apps/engine/task_validation.py @@ -2,26 +2,109 @@ # # SPDX-License-Identifier: MIT -from collections.abc import Mapping, Sequence -from typing import Generic, TypeVar +from __future__ import annotations +from typing import Callable, Generic, Iterable, Mapping, Sequence, TypeVar + +import attrs import numpy as np -_T = TypeVar("_T") +_K = TypeVar("_K") + + +@attrs.define +class _BaggedCounter(Generic[_K]): + # Stores items with count = k in a single "bag". Bags are stored in the ascending order + bags: dict[ + int, + dict[_K, None], + # dict is used instead of a set to preserve item order. It's also more performant + ] + + @staticmethod + def from_dict(item_counts: Mapping[_K, int]) -> _BaggedCounter: + return _BaggedCounter.from_counts(item_counts, item_count=item_counts.__getitem__) + + @staticmethod + def from_counts(items: Sequence[_K], item_count: Callable[[_K], int]) -> _BaggedCounter: + bags = {} + for item in items: + count = item_count(item) + bags.setdefault(count, dict())[item] = None + + return _BaggedCounter(bags=bags) + + def __attrs_post_init__(self): + self._sort_bags() + + def _sort_bags(self): + self.bags = dict(sorted(self.bags.items(), key=lambda e: e[0])) + + def shuffle(self, *, rng: np.random.Generator | None): + if not rng: + rng = np.random.default_rng() + + for count, bag in self.bags.items(): + items = list(bag.items()) + rng.shuffle(items) + self.bags[count] = dict(items) + + def use_item(self, item: _K, *, count: int | None = None, bag: dict | None = None): + if count is not None: + if bag is None: + bag = self.bags[count] + elif count is None and bag is None: + count, bag = next((c, b) for c, b in self.bags.items() if item in b) + else: + raise AssertionError("'bag' can only be used together with 'count'") + bag.pop(item) -class HoneypotFrameSelector(Generic[_T]): + if not bag: + self.bags.pop(count) + + next_bag = self.bags.get(count + 1) + if next_bag is None: + next_bag = {} + self.bags[count + 1] = next_bag + self._sort_bags() # the new bag can be added in the wrong position if there were gaps + + next_bag[item] = None + + def __iter__(self) -> Iterable[tuple[int, _K, dict]]: + for count, bag in self.bags.items(): # bags must be ordered + for item in bag: + yield (count, item, bag) + + def select_next_least_used(self, count: int) -> Sequence[_K]: + pick = [None] * count + pick_original_use_counts = [(None, None)] * count + for i, (use_count, item, bag) in zip(range(count), self): + pick[i] = item + pick_original_use_counts[i] = (use_count, bag) + + for item, (use_count, bag) in zip(pick, pick_original_use_counts): + self.use_item(item, count=use_count, bag=bag) + + return pick + + +class HoneypotFrameSelector(Generic[_K]): def __init__( - self, validation_frame_counts: Mapping[_T, int], *, rng: np.random.Generator | None = None + self, + validation_frame_counts: Mapping[_K, int], + *, + rng: np.random.Generator | None = None, ): - self.validation_frame_counts = validation_frame_counts - if not rng: rng = np.random.default_rng() self.rng = rng - def select_next_frames(self, count: int) -> Sequence[_T]: + self._counter = _BaggedCounter.from_dict(validation_frame_counts) + self._counter.shuffle(rng=rng) + + def select_next_frames(self, count: int) -> Sequence[_K]: # This approach guarantees that: # - every GT frame is used # - GT frames are used uniformly (at most min count + 1) @@ -29,20 +112,8 @@ def select_next_frames(self, count: int) -> Sequence[_T]: # - honeypot sets are different in jobs # - honeypot sets are random # if possible (if the job and GT counts allow this). - pick = [] - - for random_number in self.rng.random(count): - least_count = min(c for f, c in self.validation_frame_counts.items() if f not in pick) - least_used_frames = tuple( - f - for f, c in self.validation_frame_counts.items() - if f not in pick - if c == least_count - ) - - selected_item = int(random_number * len(least_used_frames)) - selected_frame = least_used_frames[selected_item] - pick.append(selected_frame) - self.validation_frame_counts[selected_frame] += 1 - - return pick + # Picks must be reproducible for a given rng state. + """ + Selects 'count' least used items randomly, without repetition + """ + return self._counter.select_next_least_used(count) From 5702a04dd08c53448a784cae3da9c600d10b985c Mon Sep 17 00:00:00 2001 From: Roman Donchenko Date: Tue, 7 Jan 2025 11:02:10 +0200 Subject: [PATCH 22/31] Bump pyyaml in test requirements (#8900) This allows to run tests with Python 3.12+. --- tests/python/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/python/requirements.txt b/tests/python/requirements.txt index d43d9b61d5df..dc21498a1ec0 100644 --- a/tests/python/requirements.txt +++ b/tests/python/requirements.txt @@ -8,7 +8,7 @@ deepdiff==7.0.1 boto3==1.17.61 Pillow==10.3.0 python-dateutil==2.8.2 -pyyaml==6.0.0 +pyyaml==6.0.2 numpy==2.0.0 # TODO: update pytest to 7.0.0 and pytest-timeout to 2.3.1 (better debug in vscode) \ No newline at end of file From 5d22b66c1bf156de9b6c87b86292ab045b0ee965 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jan 2025 13:17:34 +0200 Subject: [PATCH 23/31] Bump cross-spawn from 7.0.3 to 7.0.6 in /tests (#8876) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- tests/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/yarn.lock b/tests/yarn.lock index a5500151360a..4b82218c14a9 100644 --- a/tests/yarn.lock +++ b/tests/yarn.lock @@ -1146,9 +1146,9 @@ crc32-stream@^4.0.2: readable-stream "^3.4.0" cross-spawn@^7.0.0, cross-spawn@^7.0.3: - version "7.0.3" - resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== dependencies: path-key "^3.1.0" shebang-command "^2.0.0" From 8b621c2b6238d8e220a0dac0bfdc6fd4a7705ad6 Mon Sep 17 00:00:00 2001 From: Dmitrii Lavrukhin Date: Tue, 7 Jan 2025 18:40:41 +0400 Subject: [PATCH 24/31] ultralytics yolo support for tracks (#8883) ### Motivation and context Can now import Ultralytics Yolo formats with track_id Can now export Ultralytics Yolo Detection with track_id depends on https://github.com/cvat-ai/datumaro/pull/70 ### How has this been tested? ### Checklist - [ ] I submit my changes into the `develop` branch - [ ] I have created a changelog fragment - [ ] I have updated the documentation accordingly - [ ] I have added tests to cover my changes - [ ] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) - [ ] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/cvat-ai/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/cvat-ai/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/cvat-ai/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/cvat-ai/cvat/tree/develop/cvat-ui#versioning)) ### License - [ ] I submit _my code changes_ under the same [MIT License]( https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. ## Summary by CodeRabbit ## Release Notes - **New Features** - Expanded dataset import formats to include multiple YOLO formats. - Introduced new classes for annotation transformations: `MaskConverter`, `EllipsesToMasks`, `MaskToPolygonTransformation`, and `SetKeyframeForEveryTrackShape`. - Added support for exporting YOLO detection data with track IDs. - **Bug Fixes** - Minor formatting adjustments to ensure existing formats remain intact. - **Tests** - Added new export and import formats for "Ultralytics YOLO Detection Track 1.0" in the test suite. - **Chores** - Updated the `datumaro` package to a newer version in requirements files. --------- Co-authored-by: Maxim Zhiltsov --- ...07_163424_dmitrii.lavrukhin_yolo_tracks.md | 4 ++ cvat/apps/dataset_manager/bindings.py | 6 ++- .../formats/transformations.py | 13 +++++ cvat/apps/dataset_manager/formats/yolo.py | 32 ++++++++++--- .../tests/assets/annotations.json | 48 +++++++++++++++++++ .../dataset_manager/tests/test_formats.py | 1 + .../tests/test_rest_api_formats.py | 5 ++ cvat/apps/engine/tests/test_rest_api.py | 3 ++ cvat/requirements/base.in | 2 +- cvat/requirements/base.txt | 8 ++-- utils/dataset_manifest/requirements.txt | 2 +- 11 files changed, 110 insertions(+), 14 deletions(-) create mode 100644 changelog.d/20250107_163424_dmitrii.lavrukhin_yolo_tracks.md diff --git a/changelog.d/20250107_163424_dmitrii.lavrukhin_yolo_tracks.md b/changelog.d/20250107_163424_dmitrii.lavrukhin_yolo_tracks.md new file mode 100644 index 000000000000..68c2d85fdf1c --- /dev/null +++ b/changelog.d/20250107_163424_dmitrii.lavrukhin_yolo_tracks.md @@ -0,0 +1,4 @@ +### Added + +- Ultralytics YOLO formats now support tracks + () diff --git a/cvat/apps/dataset_manager/bindings.py b/cvat/apps/dataset_manager/bindings.py index a4688d80ba5a..7fddcb198f35 100644 --- a/cvat/apps/dataset_manager/bindings.py +++ b/cvat/apps/dataset_manager/bindings.py @@ -2185,7 +2185,11 @@ def import_dm_annotations(dm_dataset: dm.Dataset, instance_data: Union[ProjectDa 'coco', 'coco_instances', 'coco_person_keypoints', - 'voc' + 'voc', + 'yolo_ultralytics_detection', + 'yolo_ultralytics_segmentation', + 'yolo_ultralytics_oriented_boxes', + 'yolo_ultralytics_pose', ] label_cat = dm_dataset.categories()[dm.AnnotationType.label] diff --git a/cvat/apps/dataset_manager/formats/transformations.py b/cvat/apps/dataset_manager/formats/transformations.py index a8d361a00168..496786126709 100644 --- a/cvat/apps/dataset_manager/formats/transformations.py +++ b/cvat/apps/dataset_manager/formats/transformations.py @@ -37,6 +37,7 @@ def transform_item(self, item): return item.wrap(annotations=annotations) + class MaskConverter: @staticmethod def cvat_rle_to_dm_rle(shape, img_h: int, img_w: int) -> dm.RleMask: @@ -100,6 +101,7 @@ def rle(cls, arr: np.ndarray) -> list[int]: return cvat_rle + class EllipsesToMasks: @staticmethod def convert_ellipse(ellipse, img_h, img_w): @@ -115,6 +117,7 @@ def convert_ellipse(ellipse, img_h, img_w): return dm.RleMask(rle=rle, label=ellipse.label, z_order=ellipse.z_order, attributes=ellipse.attributes, group=ellipse.group) + class MaskToPolygonTransformation: """ Manages common logic for mask to polygons conversion in dataset import. @@ -130,3 +133,13 @@ def convert_dataset(cls, dataset, **kwargs): if kwargs.get('conv_mask_to_poly', True): dataset.transform('masks_to_polygons') return dataset + + +class SetKeyframeForEveryTrackShape(dm.ItemTransform): + def transform_item(self, item): + annotations = [] + for ann in item.annotations: + if "track_id" in ann.attributes: + ann = ann.wrap(attributes=dict(ann.attributes, keyframe=True)) + annotations.append(ann) + return item.wrap(annotations=annotations) diff --git a/cvat/apps/dataset_manager/formats/yolo.py b/cvat/apps/dataset_manager/formats/yolo.py index fac91eae4d35..2bcfdfca1325 100644 --- a/cvat/apps/dataset_manager/formats/yolo.py +++ b/cvat/apps/dataset_manager/formats/yolo.py @@ -4,6 +4,7 @@ # SPDX-License-Identifier: MIT import os.path as osp from glob import glob +from typing import Callable, Optional from datumaro.components.annotation import AnnotationType from datumaro.components.extractor import DatasetItem @@ -11,7 +12,9 @@ from pyunpack import Archive from cvat.apps.dataset_manager.bindings import ( + CommonData, GetCVATDataExtractor, + ProjectData, detect_dataset, find_dataset_root, import_dm_annotations, @@ -20,12 +23,21 @@ from cvat.apps.dataset_manager.util import make_zip_archive from .registry import dm_env, exporter, importer +from .transformations import SetKeyframeForEveryTrackShape -def _export_common(dst_file, temp_dir, instance_data, format_name, *, save_images=False): +def _export_common( + dst_file: str, + temp_dir: str, + instance_data: ProjectData | CommonData, + format_name: str, + *, + save_images: bool = False, + **kwargs +): with GetCVATDataExtractor(instance_data, include_images=save_images) as extractor: dataset = Dataset.from_extractors(extractor, env=dm_env) - dataset.export(temp_dir, format_name, save_images=save_images) + dataset.export(temp_dir, format_name, save_images=save_images, **kwargs) make_zip_archive(temp_dir, dst_file) @@ -37,12 +49,12 @@ def _export_yolo(*args, **kwargs): def _import_common( src_file, - temp_dir, - instance_data, - format_name, + temp_dir: str, + instance_data: ProjectData | CommonData, + format_name: str, *, - load_data_callback=None, - import_kwargs=None, + load_data_callback: Optional[Callable] = None, + import_kwargs: dict | None = None, **kwargs ): Archive(src_file.name).extractall(temp_dir) @@ -67,6 +79,7 @@ def _import_common( detect_dataset(temp_dir, format_name=format_name, importer=dm_env.importers.get(format_name)) dataset = Dataset.import_from(temp_dir, format_name, env=dm_env, image_info=image_info, **(import_kwargs or {})) + dataset = dataset.transform(SetKeyframeForEveryTrackShape) if load_data_callback is not None: load_data_callback(dataset, instance_data) import_dm_annotations(dataset, instance_data) @@ -82,6 +95,11 @@ def _export_yolo_ultralytics_detection(*args, **kwargs): _export_common(*args, format_name='yolo_ultralytics_detection', **kwargs) +@exporter(name='Ultralytics YOLO Detection Track', ext='ZIP', version='1.0') +def _export_yolo_ultralytics_detection_track(*args, **kwargs): + _export_common(*args, format_name='yolo_ultralytics_detection', write_track_id=True, **kwargs) + + @exporter(name='Ultralytics YOLO Oriented Bounding Boxes', ext='ZIP', version='1.0') def _export_yolo_ultralytics_oriented_boxes(*args, **kwargs): _export_common(*args, format_name='yolo_ultralytics_oriented_boxes', **kwargs) diff --git a/cvat/apps/dataset_manager/tests/assets/annotations.json b/cvat/apps/dataset_manager/tests/assets/annotations.json index a0c9e8ff96d5..2a1d7f70696c 100644 --- a/cvat/apps/dataset_manager/tests/assets/annotations.json +++ b/cvat/apps/dataset_manager/tests/assets/annotations.json @@ -1008,6 +1008,54 @@ ], "tracks": [] }, + "Ultralytics YOLO Detection Track 1.0": { + "version": 0, + "tags": [], + "shapes": [ + { + "type": "rectangle", + "occluded": false, + "z_order": 0, + "points": [0.3, 0.1, 0.2, 0.8], + "frame": 0, + "label_id": null, + "group": 0, + "source": "manual", + "attributes": [] + } + ], + "tracks": [ + { + "frame": 0, + "label_id": null, + "group": 0, + "source": "manual", + "shapes": [ + { + "type": "rectangle", + "occluded": false, + "z_order": 0, + "points": [0.2, 0.1, 0.2, 0.8], + "frame": 0, + "outside": false, + "attributes": [], + "keyframe": true + }, + { + "type": "rectangle", + "occluded": false, + "z_order": 0, + "points": [0.4, 0.1, 0.2, 0.8], + "frame": 1, + "outside": true, + "attributes": [], + "keyframe": true + } + ], + "attributes": [] + } + ] + }, "Ultralytics YOLO Oriented Bounding Boxes 1.0": { "version": 0, "tags": [], diff --git a/cvat/apps/dataset_manager/tests/test_formats.py b/cvat/apps/dataset_manager/tests/test_formats.py index 9dced8542c21..097884092de0 100644 --- a/cvat/apps/dataset_manager/tests/test_formats.py +++ b/cvat/apps/dataset_manager/tests/test_formats.py @@ -300,6 +300,7 @@ def test_export_formats_query(self): 'Ultralytics YOLO Classification 1.0', 'Ultralytics YOLO Oriented Bounding Boxes 1.0', 'Ultralytics YOLO Detection 1.0', + 'Ultralytics YOLO Detection Track 1.0', 'Ultralytics YOLO Pose 1.0', 'Ultralytics YOLO Segmentation 1.0', }) diff --git a/cvat/apps/dataset_manager/tests/test_rest_api_formats.py b/cvat/apps/dataset_manager/tests/test_rest_api_formats.py index 7be4f0e753a4..fe1addd2cbc5 100644 --- a/cvat/apps/dataset_manager/tests/test_rest_api_formats.py +++ b/cvat/apps/dataset_manager/tests/test_rest_api_formats.py @@ -59,6 +59,7 @@ "Ultralytics YOLO Classification 1.0", "YOLO 1.1", "Ultralytics YOLO Detection 1.0", + "Ultralytics YOLO Detection Track 1.0", "Ultralytics YOLO Segmentation 1.0", "Ultralytics YOLO Oriented Bounding Boxes 1.0", "Ultralytics YOLO Pose 1.0", @@ -980,6 +981,8 @@ def test_api_v2_rewriting_annotations(self): if dump_format_name == "CVAT for images 1.1" or dump_format_name == "CVAT for video 1.1": dump_format_name = "CVAT 1.1" + elif dump_format_name == "Ultralytics YOLO Detection Track 1.0": + dump_format_name = "Ultralytics YOLO Detection 1.0" url = self._generate_url_upload_tasks_annotations(task_id, dump_format_name) with open(file_zip_name, 'rb') as binary_file: @@ -1093,6 +1096,8 @@ def test_api_v2_tasks_annotations_dump_and_upload_with_datumaro(self): # upload annotations if dump_format_name in ["CVAT for images 1.1", "CVAT for video 1.1"]: upload_format_name = "CVAT 1.1" + elif dump_format_name in ['Ultralytics YOLO Detection Track 1.0']: + upload_format_name = 'Ultralytics YOLO Detection 1.0' else: upload_format_name = dump_format_name url = self._generate_url_upload_tasks_annotations(task_id, upload_format_name) diff --git a/cvat/apps/engine/tests/test_rest_api.py b/cvat/apps/engine/tests/test_rest_api.py index 3a2f5d6e8a82..d59c310e5a3c 100644 --- a/cvat/apps/engine/tests/test_rest_api.py +++ b/cvat/apps/engine/tests/test_rest_api.py @@ -6398,6 +6398,9 @@ def _get_initial_annotation(annotation_format): formats['CVAT for video 1.1'] = 'CVAT 1.1' if 'CVAT for images 1.1' in export_formats: formats['CVAT for images 1.1'] = 'CVAT 1.1' + if 'Ultralytics YOLO Detection 1.0' in import_formats: + if 'Ultralytics YOLO Detection Track 1.0' in export_formats: + formats['Ultralytics YOLO Detection Track 1.0'] = 'Ultralytics YOLO Detection 1.0' if set(import_formats) ^ set(export_formats): # NOTE: this may not be an error, so we should not fail print("The following import formats have no pair:", diff --git a/cvat/requirements/base.in b/cvat/requirements/base.in index b0d1a8f82e0b..b373d315055a 100644 --- a/cvat/requirements/base.in +++ b/cvat/requirements/base.in @@ -12,7 +12,7 @@ azure-storage-blob==12.13.0 boto3==1.17.61 clickhouse-connect==0.6.8 coreapi==2.3.3 -datumaro @ git+https://github.com/cvat-ai/datumaro.git@02507a9d41413327ca2d67a4349e5553ce763a7c +datumaro @ git+https://github.com/cvat-ai/datumaro.git@fad7636b79889f0c7b8fb0c3010b894324c4c18a dj-pagination==2.5.0 # Despite direct indication allauth in requirements we should keep 'with_social' for dj-rest-auth # to avoid possible further versions conflicts (we use registration functionality) diff --git a/cvat/requirements/base.txt b/cvat/requirements/base.txt index 45db95819864..94ec4c140c95 100644 --- a/cvat/requirements/base.txt +++ b/cvat/requirements/base.txt @@ -1,4 +1,4 @@ -# SHA1:8d2c3b645ed1f9efb7ca325d5da7eb9e07aced87 +# SHA1:d210065224fc86c1fba6b5d22b77ab38d02bcbb1 # # This file is autogenerated by pip-compile-multi # To update, run: @@ -56,7 +56,7 @@ cryptography==44.0.0 # pyjwt cycler==0.12.1 # via matplotlib -datumaro @ git+https://github.com/cvat-ai/datumaro.git@02507a9d41413327ca2d67a4349e5553ce763a7c +datumaro @ git+https://github.com/cvat-ai/datumaro.git@fad7636b79889f0c7b8fb0c3010b894324c4c18a # via -r cvat/requirements/base.in defusedxml==0.7.1 # via @@ -242,7 +242,7 @@ pyjwt[crypto]==2.10.1 # via django-allauth pylogbeat==2.0.1 # via python-logstash-async -pyparsing==3.2.0 +pyparsing==3.2.1 # via matplotlib pyrsistent==0.20.0 # via jsonschema @@ -308,7 +308,7 @@ rq-scheduler==0.13.1 # via -r cvat/requirements/base.in rsa==4.9 # via google-auth -ruamel-yaml==0.18.7 +ruamel-yaml==0.18.8 # via datumaro ruamel-yaml-clib==0.2.12 # via ruamel-yaml diff --git a/utils/dataset_manifest/requirements.txt b/utils/dataset_manifest/requirements.txt index 6d3ed66aecb1..c073606622ed 100644 --- a/utils/dataset_manifest/requirements.txt +++ b/utils/dataset_manifest/requirements.txt @@ -13,7 +13,7 @@ numpy==1.22.4 # via opencv-python-headless opencv-python-headless==4.10.0.84 # via -r utils/dataset_manifest/requirements.in -pillow==11.0.0 +pillow==11.1.0 # via -r utils/dataset_manifest/requirements.in tqdm==4.67.1 # via -r utils/dataset_manifest/requirements.in From 39825ad34bcf3616d845c45e20c0cd3be204a2d9 Mon Sep 17 00:00:00 2001 From: Roman Donchenko Date: Wed, 8 Jan 2025 12:02:43 +0200 Subject: [PATCH 25/31] Blacken most apps (#8902) These apps have either no or very few PRs that update them, so this seems like a good time to reformat them. --- .../dataset_repo/migrations/0001_initial.py | 26 +- .../migrations/0002_auto_20190123_1305.py | 10 +- .../migrations/0003_gitdata_lfs.py | 8 +- .../dataset_repo/migrations/0004_rename.py | 9 +- .../migrations/0005_auto_20201019_1100.py | 4 +- .../migrations/0006_gitdata_format.py | 16 +- cvat/apps/events/apps.py | 3 +- cvat/apps/events/cache.py | 28 +- cvat/apps/events/const.py | 2 +- cvat/apps/events/event.py | 6 +- cvat/apps/events/export.py | 83 +- cvat/apps/events/handlers.py | 58 +- cvat/apps/events/permissions.py | 26 +- .../tests/generators/events_test.gen.rego.py | 16 +- cvat/apps/events/serializers.py | 69 +- cvat/apps/events/tests/test_events.py | 125 +- cvat/apps/events/urls.py | 3 +- cvat/apps/events/utils.py | 21 +- cvat/apps/events/views.py | 129 +- cvat/apps/health/apps.py | 3 +- cvat/apps/health/backends.py | 2 +- .../health/management/commands/workerprobe.py | 16 +- cvat/apps/iam/admin.py | 33 +- cvat/apps/iam/apps.py | 3 +- cvat/apps/iam/authentication.py | 27 +- cvat/apps/iam/filters.py | 50 +- cvat/apps/iam/forms.py | 32 +- cvat/apps/iam/middleware.py | 30 +- .../migrations/0001_remove_business_group.py | 2 +- cvat/apps/iam/models.py | 1 - cvat/apps/iam/permissions.py | 112 +- cvat/apps/iam/rules/tests/generate_tests.py | 5 +- cvat/apps/iam/schema.py | 51 +- cvat/apps/iam/serializers.py | 69 +- cvat/apps/iam/signals.py | 20 +- cvat/apps/iam/tests/test_rest_api.py | 95 +- cvat/apps/iam/urls.py | 41 +- cvat/apps/iam/utils.py | 13 +- cvat/apps/iam/views.py | 63 +- cvat/apps/lambda_manager/apps.py | 3 +- cvat/apps/lambda_manager/permissions.py | 38 +- .../tests/generators/lambda_test.gen.rego.py | 16 +- cvat/apps/lambda_manager/serializers.py | 33 +- cvat/apps/lambda_manager/tests/test_lambda.py | 1039 ++++++++++------- cvat/apps/lambda_manager/urls.py | 10 +- cvat/apps/lambda_manager/views.py | 748 +++++++----- cvat/apps/log_viewer/apps.py | 3 +- cvat/apps/log_viewer/permissions.py | 18 +- .../generators/analytics_test.gen.rego.py | 38 +- cvat/apps/log_viewer/urls.py | 3 +- cvat/apps/log_viewer/views.py | 4 +- cvat/apps/organizations/__init__.py | 1 - cvat/apps/organizations/admin.py | 16 +- cvat/apps/organizations/apps.py | 3 +- .../organizations/migrations/0001_initial.py | 107 +- cvat/apps/organizations/models.py | 58 +- cvat/apps/organizations/permissions.py | 172 ++- .../generators/invitations_test.gen.rego.py | 16 +- .../generators/memberships_test.gen.rego.py | 22 +- .../generators/organizations_test.gen.rego.py | 16 +- cvat/apps/organizations/serializers.py | 107 +- cvat/apps/organizations/throttle.py | 2 +- cvat/apps/organizations/urls.py | 6 +- cvat/apps/organizations/views.py | 293 +++-- cvat/apps/webhooks/apps.py | 1 + cvat/apps/webhooks/event_type.py | 6 +- cvat/apps/webhooks/migrations/0001_initial.py | 130 ++- .../0002_alter_webhookdelivery_status_code.py | 72 +- .../0003_alter_webhookdelivery_status_code.py | 6 +- .../0004_alter_webhook_target_url.py | 6 +- cvat/apps/webhooks/models.py | 12 +- cvat/apps/webhooks/permissions.py | 109 +- .../generators/webhooks_test.gen.rego.py | 16 +- cvat/apps/webhooks/serializers.py | 16 +- cvat/apps/webhooks/signals.py | 18 +- cvat/apps/webhooks/views.py | 47 +- pyproject.toml | 6 +- 77 files changed, 2705 insertions(+), 1822 deletions(-) diff --git a/cvat/apps/dataset_repo/migrations/0001_initial.py b/cvat/apps/dataset_repo/migrations/0001_initial.py index fb4cf35a9672..fa02f8c54b5d 100644 --- a/cvat/apps/dataset_repo/migrations/0001_initial.py +++ b/cvat/apps/dataset_repo/migrations/0001_initial.py @@ -9,23 +9,31 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('engine', '0014_job_max_shape_id'), + ("engine", "0014_job_max_shape_id"), ] - replaces = [('git', '0001_initial')] + replaces = [("git", "0001_initial")] operations = [ migrations.CreateModel( - name='GitData', + name="GitData", fields=[ - ('task', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='engine.Task')), - ('url', models.URLField(max_length=2000)), - ('path', models.CharField(max_length=256)), - ('sync_date', models.DateTimeField(auto_now_add=True)), - ('status', models.CharField(default='!sync', max_length=20)), + ( + "task", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + serialize=False, + to="engine.Task", + ), + ), + ("url", models.URLField(max_length=2000)), + ("path", models.CharField(max_length=256)), + ("sync_date", models.DateTimeField(auto_now_add=True)), + ("status", models.CharField(default="!sync", max_length=20)), ], options={ - 'db_table': 'git_gitdata', + "db_table": "git_gitdata", }, ), ] diff --git a/cvat/apps/dataset_repo/migrations/0002_auto_20190123_1305.py b/cvat/apps/dataset_repo/migrations/0002_auto_20190123_1305.py index 13fb92b8658e..ce0be5cbbc39 100644 --- a/cvat/apps/dataset_repo/migrations/0002_auto_20190123_1305.py +++ b/cvat/apps/dataset_repo/migrations/0002_auto_20190123_1305.py @@ -6,15 +6,15 @@ class Migration(migrations.Migration): dependencies = [ - ('dataset_repo', '0001_initial'), + ("dataset_repo", "0001_initial"), ] - replaces = [('git', '0002_auto_20190123_1305')] + replaces = [("git", "0002_auto_20190123_1305")] operations = [ migrations.AlterField( - model_name='gitdata', - name='status', - field=models.CharField(default='!sync', max_length=20), + model_name="gitdata", + name="status", + field=models.CharField(default="!sync", max_length=20), ), ] diff --git a/cvat/apps/dataset_repo/migrations/0003_gitdata_lfs.py b/cvat/apps/dataset_repo/migrations/0003_gitdata_lfs.py index b42ebd30db29..1e845e48a108 100644 --- a/cvat/apps/dataset_repo/migrations/0003_gitdata_lfs.py +++ b/cvat/apps/dataset_repo/migrations/0003_gitdata_lfs.py @@ -6,15 +6,15 @@ class Migration(migrations.Migration): dependencies = [ - ('dataset_repo', '0002_auto_20190123_1305'), + ("dataset_repo", "0002_auto_20190123_1305"), ] - replaces = [('git', '0003_gitdata_lfs')] + replaces = [("git", "0003_gitdata_lfs")] operations = [ migrations.AddField( - model_name='gitdata', - name='lfs', + model_name="gitdata", + name="lfs", field=models.BooleanField(default=True), ), ] diff --git a/cvat/apps/dataset_repo/migrations/0004_rename.py b/cvat/apps/dataset_repo/migrations/0004_rename.py index 05072160398e..94b820dcaa56 100644 --- a/cvat/apps/dataset_repo/migrations/0004_rename.py +++ b/cvat/apps/dataset_repo/migrations/0004_rename.py @@ -2,16 +2,17 @@ def update_contenttypes_table(apps, schema_editor): - content_type_model = apps.get_model('contenttypes', 'ContentType') - content_type_model.objects.filter(app_label='git').update(app_label='dataset_repo') + content_type_model = apps.get_model("contenttypes", "ContentType") + content_type_model.objects.filter(app_label="git").update(app_label="dataset_repo") + class Migration(migrations.Migration): dependencies = [ - ('dataset_repo', '0003_gitdata_lfs'), + ("dataset_repo", "0003_gitdata_lfs"), ] operations = [ - migrations.AlterModelTable('gitdata', 'dataset_repo_gitdata'), + migrations.AlterModelTable("gitdata", "dataset_repo_gitdata"), migrations.RunPython(update_contenttypes_table), ] diff --git a/cvat/apps/dataset_repo/migrations/0005_auto_20201019_1100.py b/cvat/apps/dataset_repo/migrations/0005_auto_20201019_1100.py index f26c280b7f84..8c07d05d29f3 100644 --- a/cvat/apps/dataset_repo/migrations/0005_auto_20201019_1100.py +++ b/cvat/apps/dataset_repo/migrations/0005_auto_20201019_1100.py @@ -6,12 +6,12 @@ class Migration(migrations.Migration): dependencies = [ - ('dataset_repo', '0004_rename'), + ("dataset_repo", "0004_rename"), ] operations = [ migrations.AlterModelTable( - name='gitdata', + name="gitdata", table=None, ), ] diff --git a/cvat/apps/dataset_repo/migrations/0006_gitdata_format.py b/cvat/apps/dataset_repo/migrations/0006_gitdata_format.py index 641d246743eb..1b42f2d3caea 100644 --- a/cvat/apps/dataset_repo/migrations/0006_gitdata_format.py +++ b/cvat/apps/dataset_repo/migrations/0006_gitdata_format.py @@ -4,21 +4,27 @@ def update_default_format_field(apps, schema_editor): - GitData = apps.get_model('dataset_repo', 'GitData') + GitData = apps.get_model("dataset_repo", "GitData") for git_data in GitData.objects.all(): if not git_data.format: - git_data.format = 'CVAT for images 1.1' if git_data.task.mode == 'annotation' else 'CVAT for video 1.1' + git_data.format = ( + "CVAT for images 1.1" + if git_data.task.mode == "annotation" + else "CVAT for video 1.1" + ) git_data.save() + + class Migration(migrations.Migration): dependencies = [ - ('dataset_repo', '0005_auto_20201019_1100'), + ("dataset_repo", "0005_auto_20201019_1100"), ] operations = [ migrations.AddField( - model_name='gitdata', - name='format', + model_name="gitdata", + name="format", field=models.CharField(blank=True, max_length=256), ), migrations.RunPython(update_default_format_field), diff --git a/cvat/apps/events/apps.py b/cvat/apps/events/apps.py index 17c42e754f1b..c4a7b0a3d9b4 100644 --- a/cvat/apps/events/apps.py +++ b/cvat/apps/events/apps.py @@ -6,10 +6,11 @@ class EventsConfig(AppConfig): - name = 'cvat.apps.events' + name = "cvat.apps.events" def ready(self): from cvat.apps.iam.permissions import load_app_permissions + load_app_permissions(self) from . import signals # pylint: disable=unused-import diff --git a/cvat/apps/events/cache.py b/cvat/apps/events/cache.py index 168211a1e24f..d17a8e703bc1 100644 --- a/cvat/apps/events/cache.py +++ b/cvat/apps/events/cache.py @@ -4,36 +4,42 @@ _caches = {} -class DeleteCache(): + +class DeleteCache: def __init__(self, cache_id): from cvat.apps.engine.models import Comment, Issue, Job, Task - self._cache = _caches.setdefault(cache_id, { - Task: {}, - Job: {}, - Issue: {}, - Comment: {}, - }) + + self._cache = _caches.setdefault( + cache_id, + { + Task: {}, + Job: {}, + Issue: {}, + Comment: {}, + }, + ) def set(self, instance_class, instance_id, value): self._cache[instance_class][instance_id] = value def pop(self, instance_class, instance_id, default=None): - if instance_class in self._cache and \ - instance_id in self._cache[instance_class]: + if instance_class in self._cache and instance_id in self._cache[instance_class]: return self._cache[instance_class].pop(instance_id, default) def has_key(self, instance_class, instance_id): - if instance_class in self._cache and \ - instance_id in self._cache[instance_class]: + if instance_class in self._cache and instance_id in self._cache[instance_class]: return True return False def clear(self): self._cache.clear() + def get_cache(): from .handlers import request_id + return DeleteCache(request_id()) + def clear_cache(): get_cache().clear() diff --git a/cvat/apps/events/const.py b/cvat/apps/events/const.py index 35df51c7adb0..9291d9397be3 100644 --- a/cvat/apps/events/const.py +++ b/cvat/apps/events/const.py @@ -6,5 +6,5 @@ MAX_EVENT_DURATION = datetime.timedelta(seconds=100) WORKING_TIME_RESOLUTION = datetime.timedelta(milliseconds=1) -WORKING_TIME_SCOPE = 'send:working_time' +WORKING_TIME_SCOPE = "send:working_time" COMPRESSED_EVENT_SCOPES = frozenset(("change:frame",)) diff --git a/cvat/apps/events/event.py b/cvat/apps/events/event.py index ebb00496b36c..5368367b70d7 100644 --- a/cvat/apps/events/event.py +++ b/cvat/apps/events/event.py @@ -42,6 +42,7 @@ def select(cls, resources): for action in cls.RESOURCES.get(resource, []) ] + def record_server_event( *, scope: str, @@ -64,11 +65,11 @@ def record_server_event( "scope": scope, "timestamp": str(datetime.now(timezone.utc).timestamp()), "source": "server", - "payload": JSONRenderer().render(payload_with_request_id).decode('UTF-8'), + "payload": JSONRenderer().render(payload_with_request_id).decode("UTF-8"), **kwargs, } - rendered_data = JSONRenderer().render(data).decode('UTF-8') + rendered_data = JSONRenderer().render(data).decode("UTF-8") if on_commit: transaction.on_commit(lambda: vlogger.info(rendered_data), robust=True) @@ -81,6 +82,7 @@ class EventScopeChoice: def choices(cls): return sorted((val, val.upper()) for val in AllEvents.events) + class AllEvents: events = list( event_scope(action, resource) diff --git a/cvat/apps/events/export.py b/cvat/apps/events/export.py index aa70fcdc066e..770f84dda054 100644 --- a/cvat/apps/events/export.py +++ b/cvat/apps/events/export.py @@ -24,26 +24,27 @@ DEFAULT_CACHE_TTL = timedelta(hours=1) + def _create_csv(query_params, output_filename, cache_ttl): try: - clickhouse_settings = settings.CLICKHOUSE['events'] + clickhouse_settings = settings.CLICKHOUSE["events"] time_filter = { - 'from': query_params.pop('from'), - 'to': query_params.pop('to'), + "from": query_params.pop("from"), + "to": query_params.pop("to"), } query = "SELECT * FROM events" conditions = [] parameters = {} - if time_filter['from']: + if time_filter["from"]: conditions.append(f"timestamp >= {{from:DateTime64}}") - parameters['from'] = time_filter['from'] + parameters["from"] = time_filter["from"] - if time_filter['to']: + if time_filter["to"]: conditions.append(f"timestamp <= {{to:DateTime64}}") - parameters['to'] = time_filter['to'] + parameters["to"] = time_filter["to"] for param, value in query_params.items(): if value: @@ -56,22 +57,23 @@ def _create_csv(query_params, output_filename, cache_ttl): query += " ORDER BY timestamp ASC" with clickhouse_connect.get_client( - host=clickhouse_settings['HOST'], - database=clickhouse_settings['NAME'], - port=clickhouse_settings['PORT'], - username=clickhouse_settings['USER'], - password=clickhouse_settings['PASSWORD'], + host=clickhouse_settings["HOST"], + database=clickhouse_settings["NAME"], + port=clickhouse_settings["PORT"], + username=clickhouse_settings["USER"], + password=clickhouse_settings["PASSWORD"], ) as client: result = client.query(query, parameters=parameters) - with open(output_filename, 'w', encoding='UTF8') as f: + with open(output_filename, "w", encoding="UTF8") as f: writer = csv.writer(f) writer.writerow(result.column_names) writer.writerows(result.result_rows) archive_ctime = os.path.getctime(output_filename) scheduler = django_rq.get_scheduler(settings.CVAT_QUEUES.EXPORT_DATA.value) - cleaning_job = scheduler.enqueue_in(time_delta=cache_ttl, + cleaning_job = scheduler.enqueue_in( + time_delta=cache_ttl, func=_clear_export_cache, file_path=output_filename, file_ctime=archive_ctime, @@ -87,36 +89,37 @@ def _create_csv(query_params, output_filename, cache_ttl): log_exception(slogger.glob) raise + def export(request, filter_query, queue_name): - action = request.query_params.get('action', None) - filename = request.query_params.get('filename', None) + action = request.query_params.get("action", None) + filename = request.query_params.get("filename", None) query_params = { - 'org_id': filter_query.get('org_id', None), - 'project_id': filter_query.get('project_id', None), - 'task_id': filter_query.get('task_id', None), - 'job_id': filter_query.get('job_id', None), - 'user_id': filter_query.get('user_id', None), - 'from': filter_query.get('from', None), - 'to': filter_query.get('to', None), + "org_id": filter_query.get("org_id", None), + "project_id": filter_query.get("project_id", None), + "task_id": filter_query.get("task_id", None), + "job_id": filter_query.get("job_id", None), + "user_id": filter_query.get("user_id", None), + "from": filter_query.get("from", None), + "to": filter_query.get("to", None), } try: - if query_params['from']: - query_params['from'] = parser.parse(query_params['from']).timestamp() + if query_params["from"]: + query_params["from"] = parser.parse(query_params["from"]).timestamp() except parser.ParserError: raise serializers.ValidationError( f"Cannot parse 'from' datetime parameter: {query_params['from']}" ) try: - if query_params['to']: - query_params['to'] = parser.parse(query_params['to']).timestamp() + if query_params["to"]: + query_params["to"] = parser.parse(query_params["to"]).timestamp() except parser.ParserError: raise serializers.ValidationError( f"Cannot parse 'to' datetime parameter: {query_params['to']}" ) - if query_params['from'] and query_params['to'] and query_params['from'] > query_params['to']: + if query_params["from"] and query_params["to"] and query_params["from"] > query_params["to"]: raise serializers.ValidationError("'from' must be before than 'to'") # Set the default time interval to last 30 days @@ -124,14 +127,13 @@ def export(request, filter_query, queue_name): query_params["to"] = datetime.now(timezone.utc) query_params["from"] = query_params["to"] - timedelta(days=30) - if action not in (None, 'download'): - raise serializers.ValidationError( - "Unexpected action specified for the request") + if action not in (None, "download"): + raise serializers.ValidationError("Unexpected action specified for the request") - query_id = request.query_params.get('query_id', None) or uuid.uuid4() + query_id = request.query_params.get("query_id", None) or uuid.uuid4() rq_id = f"export:csv-logs-{query_id}-by-{request.user}" response_data = { - 'query_id': query_id, + "query_id": query_id, } queue = django_rq.get_queue(queue_name) @@ -145,16 +147,14 @@ def export(request, filter_query, queue_name): timestamp = datetime.strftime(datetime.now(), "%Y_%m_%d_%H_%M_%S") filename = filename or f"logs_{timestamp}.csv" - return sendfile(request, file_path, attachment=True, - attachment_filename=filename) + return sendfile(request, file_path, attachment=True, attachment_filename=filename) else: if os.path.exists(file_path): return Response(status=status.HTTP_201_CREATED) elif rq_job.is_failed: exc_info = rq_job.meta.get(RQJobMetaField.FORMATTED_EXCEPTION, str(rq_job.exc_info)) rq_job.delete() - return Response(exc_info, - status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return Response(exc_info, status=status.HTTP_500_INTERNAL_SERVER_ERROR) else: return Response(data=response_data, status=status.HTTP_202_ACCEPTED) @@ -165,18 +165,19 @@ def export(request, filter_query, queue_name): args=(query_params, output_filename, DEFAULT_CACHE_TTL), job_id=rq_id, meta={}, - result_ttl=ttl, failure_ttl=ttl) + result_ttl=ttl, + failure_ttl=ttl, + ) return Response(data=response_data, status=status.HTTP_202_ACCEPTED) + def _clear_export_cache(file_path: str, file_ctime: float, logger: Logger) -> None: try: if os.path.exists(file_path) and os.path.getctime(file_path) == file_ctime: os.remove(file_path) - logger.info( - "Export cache file '{}' successfully removed" \ - .format(file_path)) + logger.info("Export cache file '{}' successfully removed".format(file_path)) except Exception: log_exception(logger) raise diff --git a/cvat/apps/events/handlers.py b/cvat/apps/events/handlers.py index 58581dacaa37..69dd4b11cdd8 100644 --- a/cvat/apps/events/handlers.py +++ b/cvat/apps/events/handlers.py @@ -175,9 +175,7 @@ def organization_slug(instance): def get_instance_diff(old_data, data): - ignore_related_fields = ( - "labels", - ) + ignore_related_fields = ("labels",) diff = {} for prop, value in data.items(): if prop in ignore_related_fields: @@ -193,7 +191,7 @@ def get_instance_diff(old_data, data): def _cleanup_fields(obj: dict[str, Any]) -> dict[str, Any]: - fields=( + fields = ( "slug", "id", "name", @@ -213,9 +211,7 @@ def _cleanup_fields(obj: dict[str, Any]) -> dict[str, Any]: "attributes", "key", ) - subfields=( - "url", - ) + subfields = ("url",) data = {} for k, v in obj.items(): @@ -229,11 +225,13 @@ def _cleanup_fields(obj: dict[str, Any]) -> dict[str, Any]: def _get_object_name(instance): - if isinstance(instance, Organization) or \ - isinstance(instance, Project) or \ - isinstance(instance, Task) or \ - isinstance(instance, Job) or \ - isinstance(instance, Label): + if ( + isinstance(instance, Organization) + or isinstance(instance, Project) + or isinstance(instance, Task) + or isinstance(instance, Job) + or isinstance(instance, Label) + ): return getattr(instance, "name", None) if isinstance(instance, User): @@ -265,9 +263,7 @@ def _get_object_name(instance): def get_serializer(instance): - context = { - "request": get_current_request() - } + context = {"request": get_current_request()} serializer = None for model, serializer_class in SERIALIZERS: @@ -276,6 +272,7 @@ def get_serializer(instance): return serializer + def get_serializer_without_url(instance): serializer = get_serializer(instance) if serializer: @@ -304,7 +301,7 @@ def handle_create(scope, instance, **kwargs): scope=scope, request_id=request_id(), on_commit=True, - obj_id=getattr(instance, 'id', None), + obj_id=getattr(instance, "id", None), obj_name=_get_object_name(instance), org_id=oid, org_slug=oslug, @@ -339,7 +336,7 @@ def handle_update(scope, instance, old_instance, **kwargs): request_id=request_id(), on_commit=True, obj_name=prop, - obj_id=getattr(instance, f'{prop}_id', None), + obj_id=getattr(instance, f"{prop}_id", None), obj_val=str(change["new_value"]), org_id=oid, org_slug=oslug, @@ -494,6 +491,7 @@ def filter_track(track): payload={"tracks": tracks}, ) + def handle_dataset_io( instance: Union[Project, Task, Job], action: str, @@ -502,7 +500,7 @@ def handle_dataset_io( cloud_storage_id: Optional[int], **payload_fields, ) -> None: - payload={"format": format_name, **payload_fields} + payload = {"format": format_name, **payload_fields} if cloud_storage_id: payload["cloud_storage"] = {"id": cloud_storage_id} @@ -521,6 +519,7 @@ def handle_dataset_io( payload=payload, ) + def handle_dataset_export( instance: Union[Project, Task, Job], *, @@ -528,8 +527,14 @@ def handle_dataset_export( cloud_storage_id: Optional[int], save_images: bool, ) -> None: - handle_dataset_io(instance, "export", - format_name=format_name, cloud_storage_id=cloud_storage_id, save_images=save_images) + handle_dataset_io( + instance, + "export", + format_name=format_name, + cloud_storage_id=cloud_storage_id, + save_images=save_images, + ) + def handle_dataset_import( instance: Union[Project, Task, Job], @@ -537,7 +542,10 @@ def handle_dataset_import( format_name: str, cloud_storage_id: Optional[int], ) -> None: - handle_dataset_io(instance, "import", format_name=format_name, cloud_storage_id=cloud_storage_id) + handle_dataset_io( + instance, "import", format_name=format_name, cloud_storage_id=cloud_storage_id + ) + def handle_function_call( function_id: str, @@ -559,6 +567,7 @@ def handle_function_call( }, ) + def handle_rq_exception(rq_job, exc_type, exc_value, tb): oid = rq_job.meta.get(RQJobMetaField.ORG_ID, None) oslug = rq_job.meta.get(RQJobMetaField.ORG_SLUG, None) @@ -572,7 +581,7 @@ def handle_rq_exception(rq_job, exc_type, exc_value, tb): payload = { "message": tb_strings[-1].rstrip("\n"), - "stack": ''.join(tb_strings), + "stack": "".join(tb_strings), } record_server_event( @@ -592,10 +601,11 @@ def handle_rq_exception(rq_job, exc_type, exc_value, tb): return False + def handle_viewset_exception(exc, context): response = exception_handler(exc, context) - IGNORED_EXCEPTION_CLASSES = (NotAuthenticated, ) + IGNORED_EXCEPTION_CLASSES = (NotAuthenticated,) if isinstance(exc, IGNORED_EXCEPTION_CLASSES): return response # the standard DRF exception handler only handle APIException, Http404 and PermissionDenied @@ -618,7 +628,7 @@ def handle_viewset_exception(exc, context): "method": request.method, }, "message": tb_strings[-1].rstrip("\n"), - "stack": ''.join(tb_strings), + "stack": "".join(tb_strings), "status_code": status_code, } diff --git a/cvat/apps/events/permissions.py b/cvat/apps/events/permissions.py index eb5ea2281683..18d30f63ff65 100644 --- a/cvat/apps/events/permissions.py +++ b/cvat/apps/events/permissions.py @@ -12,13 +12,13 @@ class EventsPermission(OpenPolicyAgentPermission): class Scopes(StrEnum): - SEND_EVENTS = 'send:events' - DUMP_EVENTS = 'dump:events' + SEND_EVENTS = "send:events" + DUMP_EVENTS = "dump:events" @classmethod def create(cls, request, view, obj, iam_context): permissions = [] - if view.basename == 'events': + if view.basename == "events": for scope in cls.get_scopes(request, view, obj): self = cls.create_base_perm(request, view, scope, iam_context, obj) permissions.append(self) @@ -27,19 +27,21 @@ def create(cls, request, view, obj, iam_context): def __init__(self, **kwargs): super().__init__(**kwargs) - self.url = settings.IAM_OPA_DATA_URL + '/events/allow' + self.url = settings.IAM_OPA_DATA_URL + "/events/allow" def filter(self, query_params): - url = self.url.replace('/allow', '/filter') + url = self.url.replace("/allow", "/filter") with make_requests_session() as session: - r = session.post(url, json=self.payload).json()['result'] + r = session.post(url, json=self.payload).json()["result"] filter_params = query_params.copy() for query in r: for attr, value in query.items(): if filter_params.get(attr, value) != value: - raise PermissionDenied(f"You don't have permission to view events with {attr}={filter_params.get(attr)}") + raise PermissionDenied( + f"You don't have permission to view events with {attr}={filter_params.get(attr)}" + ) else: filter_params[attr] = value return filter_params @@ -47,10 +49,12 @@ def filter(self, query_params): @staticmethod def get_scopes(request, view, obj): Scopes = __class__.Scopes - return [{ - ('create', 'POST'): Scopes.SEND_EVENTS, - ('list', 'GET'): Scopes.DUMP_EVENTS, - }[(view.action, request.method)]] + return [ + { + ("create", "POST"): Scopes.SEND_EVENTS, + ("list", "GET"): Scopes.DUMP_EVENTS, + }[(view.action, request.method)] + ] def get_resource(self): return None diff --git a/cvat/apps/events/rules/tests/generators/events_test.gen.rego.py b/cvat/apps/events/rules/tests/generators/events_test.gen.rego.py index dee2d4a68963..a345c8369f9e 100644 --- a/cvat/apps/events/rules/tests/generators/events_test.gen.rego.py +++ b/cvat/apps/events/rules/tests/generators/events_test.gen.rego.py @@ -83,13 +83,15 @@ def get_data(scope, context, ownership, privilege, membership, resource, same_or "scope": scope, "auth": { "user": {"id": random.randrange(0, 100), "privilege": privilege}, - "organization": { - "id": random.randrange(100, 200), - "owner": {"id": random.randrange(200, 300)}, - "user": {"role": membership}, - } - if context == "organization" - else None, + "organization": ( + { + "id": random.randrange(100, 200), + "owner": {"id": random.randrange(200, 300)}, + "user": {"role": membership}, + } + if context == "organization" + else None + ), }, "resource": resource, } diff --git a/cvat/apps/events/serializers.py b/cvat/apps/events/serializers.py index 9b70f17429c9..f634fef20b87 100644 --- a/cvat/apps/events/serializers.py +++ b/cvat/apps/events/serializers.py @@ -30,19 +30,40 @@ class EventSerializer(serializers.Serializer): class ClientEventsSerializer(serializers.Serializer): ALLOWED_SCOPES = { - 'client': frozenset(( - 'load:cvat', 'load:job', 'save:job','load:workspace', - 'upload:annotations', # TODO: remove in next releases - 'lock:object', # TODO: remove in next releases - 'change:attribute', # TODO: remove in next releases - 'change:label', # TODO: remove in next releases - 'send:exception', 'join:objects', 'change:frame', - 'draw:object', 'paste:object', 'copy:object', 'propagate:object', - 'drag:object', 'resize:object', 'delete:object', - 'merge:objects', 'split:objects', 'group:objects', 'slice:object', - 'zoom:image', 'fit:image', 'rotate:image', 'action:undo', 'action:redo', - 'debug:info', 'run:annotations_action', 'click:element', - )), + "client": frozenset( + ( + "load:cvat", + "load:job", + "save:job", + "load:workspace", + "upload:annotations", # TODO: remove in next releases + "lock:object", # TODO: remove in next releases + "change:attribute", # TODO: remove in next releases + "change:label", # TODO: remove in next releases + "send:exception", + "join:objects", + "change:frame", + "draw:object", + "paste:object", + "copy:object", + "propagate:object", + "drag:object", + "resize:object", + "delete:object", + "merge:objects", + "split:objects", + "group:objects", + "slice:object", + "zoom:image", + "fit:image", + "rotate:image", + "action:undo", + "action:redo", + "debug:info", + "run:annotations_action", + "click:element", + ) + ), } events = EventSerializer(many=True, default=[]) @@ -72,18 +93,24 @@ def to_internal_value(self, data): scope = event["scope"] source = event.get("source", "client") if scope not in ClientEventsSerializer.ALLOWED_SCOPES.get(source, []): - raise serializers.ValidationError({"scope": f"Event scope **{scope}** is not allowed from {source}"}) + raise serializers.ValidationError( + {"scope": f"Event scope **{scope}** is not allowed from {source}"} + ) try: payload = json.loads(event.get("payload", "{}")) except json.JSONDecodeError: - raise serializers.ValidationError({ "payload": "JSON payload is not valid in passed event" }) + raise serializers.ValidationError( + {"payload": "JSON payload is not valid in passed event"} + ) - event.update({ - "timestamp": event["timestamp"] + time_correction, - "source": source, - "payload": json.dumps(payload), - **(user_and_org_data if source == 'client' else {}) - }) + event.update( + { + "timestamp": event["timestamp"] + time_correction, + "source": source, + "payload": json.dumps(payload), + **(user_and_org_data if source == "client" else {}), + } + ) return data diff --git a/cvat/apps/events/tests/test_events.py b/cvat/apps/events/tests/test_events.py index 28ca3f0a20e5..3b9c4a6c832c 100644 --- a/cvat/apps/events/tests/test_events.py +++ b/cvat/apps/events/tests/test_events.py @@ -38,22 +38,20 @@ def _compressed_event(timestamp: datetime, duration: timedelta) -> dict: "duration": duration // WORKING_TIME_RESOLUTION, } - @staticmethod def _get_actual_working_times(data: dict) -> list[int]: data_copy = data.copy() working_times = [] - for event in data['events']: - data_copy['events'] = [event] + for event in data["events"]: + data_copy["events"] = [event] event_working_time = compute_working_time_per_ids(data_copy) for working_time in event_working_time.values(): - working_times.append((working_time['value'] // WORKING_TIME_RESOLUTION)) - if data_copy['previous_event'] and is_contained(event, data_copy['previous_event']): + working_times.append((working_time["value"] // WORKING_TIME_RESOLUTION)) + if data_copy["previous_event"] and is_contained(event, data_copy["previous_event"]): continue - data_copy['previous_event'] = event + data_copy["previous_event"] = event return working_times - @staticmethod def _deserialize(events: list[dict], previous_event: Optional[dict] = None) -> dict: request = RequestFactory().post("/api/events") @@ -66,7 +64,7 @@ def _deserialize(events: list[dict], previous_event: Optional[dict] = None) -> d data={ "events": events, "previous_event": previous_event, - "timestamp": datetime.now(timezone.utc) + "timestamp": datetime.now(timezone.utc), }, context={"request": request}, ) @@ -76,103 +74,118 @@ def _deserialize(events: list[dict], previous_event: Optional[dict] = None) -> d return s.validated_data def test_instant(self): - data = self._deserialize([ - self._instant_event(self._START_TIMESTAMP), - ]) + data = self._deserialize( + [ + self._instant_event(self._START_TIMESTAMP), + ] + ) event_times = self._get_actual_working_times(data) self.assertEqual(event_times[0], 0) def test_compressed(self): - data = self._deserialize([ - self._compressed_event(self._START_TIMESTAMP, self._LONG_GAP), - ]) + data = self._deserialize( + [ + self._compressed_event(self._START_TIMESTAMP, self._LONG_GAP), + ] + ) event_times = self._get_actual_working_times(data) self.assertEqual(event_times[0], self._LONG_GAP_INT) def test_instants_with_short_gap(self): - data = self._deserialize([ - self._instant_event(self._START_TIMESTAMP), - self._instant_event(self._START_TIMESTAMP + self._SHORT_GAP), - ]) + data = self._deserialize( + [ + self._instant_event(self._START_TIMESTAMP), + self._instant_event(self._START_TIMESTAMP + self._SHORT_GAP), + ] + ) event_times = self._get_actual_working_times(data) self.assertEqual(event_times[0], 0) self.assertEqual(event_times[1], self._SHORT_GAP_INT) def test_instants_with_long_gap(self): - data = self._deserialize([ - self._instant_event(self._START_TIMESTAMP), - self._instant_event(self._START_TIMESTAMP + self._LONG_GAP), - ]) + data = self._deserialize( + [ + self._instant_event(self._START_TIMESTAMP), + self._instant_event(self._START_TIMESTAMP + self._LONG_GAP), + ] + ) event_times = self._get_actual_working_times(data) self.assertEqual(event_times[0], 0) self.assertEqual(event_times[1], 0) def test_compressed_with_short_gap(self): - data = self._deserialize([ - self._compressed_event(self._START_TIMESTAMP, timedelta(seconds=1)), - self._compressed_event( - self._START_TIMESTAMP + timedelta(seconds=1) + self._SHORT_GAP, - timedelta(seconds=5) - ), - ]) + data = self._deserialize( + [ + self._compressed_event(self._START_TIMESTAMP, timedelta(seconds=1)), + self._compressed_event( + self._START_TIMESTAMP + timedelta(seconds=1) + self._SHORT_GAP, + timedelta(seconds=5), + ), + ] + ) event_times = self._get_actual_working_times(data) self.assertEqual(event_times[0], 1000) self.assertEqual(event_times[1], self._SHORT_GAP_INT + 5000) def test_compressed_with_long_gap(self): - data = self._deserialize([ - self._compressed_event(self._START_TIMESTAMP, timedelta(seconds=1)), - self._compressed_event( - self._START_TIMESTAMP + timedelta(seconds=1) + self._LONG_GAP, - timedelta(seconds=5) - ), - ]) + data = self._deserialize( + [ + self._compressed_event(self._START_TIMESTAMP, timedelta(seconds=1)), + self._compressed_event( + self._START_TIMESTAMP + timedelta(seconds=1) + self._LONG_GAP, + timedelta(seconds=5), + ), + ] + ) event_times = self._get_actual_working_times(data) self.assertEqual(event_times[0], 1000) self.assertEqual(event_times[1], 5000) def test_compressed_contained(self): - data = self._deserialize([ - self._compressed_event(self._START_TIMESTAMP, timedelta(seconds=5)), - self._compressed_event( - self._START_TIMESTAMP + timedelta(seconds=3), - timedelta(seconds=1) - ), - ]) + data = self._deserialize( + [ + self._compressed_event(self._START_TIMESTAMP, timedelta(seconds=5)), + self._compressed_event( + self._START_TIMESTAMP + timedelta(seconds=3), timedelta(seconds=1) + ), + ] + ) event_times = self._get_actual_working_times(data) self.assertEqual(event_times[0], 5000) self.assertEqual(event_times[1], 0) def test_compressed_overlapping(self): - data = self._deserialize([ - self._compressed_event(self._START_TIMESTAMP, timedelta(seconds=5)), - self._compressed_event( - self._START_TIMESTAMP + timedelta(seconds=3), - timedelta(seconds=6) - ), - ]) + data = self._deserialize( + [ + self._compressed_event(self._START_TIMESTAMP, timedelta(seconds=5)), + self._compressed_event( + self._START_TIMESTAMP + timedelta(seconds=3), timedelta(seconds=6) + ), + ] + ) event_times = self._get_actual_working_times(data) self.assertEqual(event_times[0], 5000) self.assertEqual(event_times[1], 4000) def test_instant_inside_compressed(self): - data = self._deserialize([ - self._compressed_event(self._START_TIMESTAMP, timedelta(seconds=5)), - self._instant_event(self._START_TIMESTAMP + timedelta(seconds=3)), - self._instant_event(self._START_TIMESTAMP + timedelta(seconds=6)), - ]) + data = self._deserialize( + [ + self._compressed_event(self._START_TIMESTAMP, timedelta(seconds=5)), + self._instant_event(self._START_TIMESTAMP + timedelta(seconds=3)), + self._instant_event(self._START_TIMESTAMP + timedelta(seconds=6)), + ] + ) event_times = self._get_actual_working_times(data) self.assertEqual(event_times[0], 5000) self.assertEqual(event_times[1], 0) self.assertEqual(event_times[2], 1000) - def test_previous_instant_short_gap(self): data = self._deserialize( [self._instant_event(self._START_TIMESTAMP + self._SHORT_GAP)], diff --git a/cvat/apps/events/urls.py b/cvat/apps/events/urls.py index 832c86ac396b..cdb0d2032e68 100644 --- a/cvat/apps/events/urls.py +++ b/cvat/apps/events/urls.py @@ -1,4 +1,3 @@ - # Copyright (C) 2023 CVAT.ai Corporation # # SPDX-License-Identifier: MIT @@ -8,6 +7,6 @@ from . import views router = routers.DefaultRouter(trailing_slash=False) -router.register('events', views.EventsViewSet, basename='events') +router.register("events", views.EventsViewSet, basename="events") urlpatterns = router.urls diff --git a/cvat/apps/events/utils.py b/cvat/apps/events/utils.py index 2a46c48beb38..31c7f83c1791 100644 --- a/cvat/apps/events/utils.py +++ b/cvat/apps/events/utils.py @@ -12,7 +12,7 @@ def _prepare_objects_to_delete(object_to_delete): from cvat.apps.engine.models import Comment, Issue, Job, Project, Segment, Task relation_chain = (Project, Task, Segment, Job, Issue, Comment) - related_field_names = ('task_set', 'segment_set', 'job_set', 'issues', 'comments') + related_field_names = ("task_set", "segment_set", "job_set", "issues", "comments") field_names = tuple(m._meta.model_name for m in relation_chain) # Find object Model @@ -25,25 +25,21 @@ def _prepare_objects_to_delete(object_to_delete): # Fill filter param filter_params = { - f'{object_to_delete.__class__._meta.model_name}_id': object_to_delete.id, + f"{object_to_delete.__class__._meta.model_name}_id": object_to_delete.id, } # Fill prefetch prefetch = [] if index < len(relation_chain) - 1: - forward_prefetch = '__'.join(related_field_names[index:]) + forward_prefetch = "__".join(related_field_names[index:]) prefetch.append(forward_prefetch) if index > 0: - backward_prefetch = '__'.join(reversed(field_names[:index])) + backward_prefetch = "__".join(reversed(field_names[:index])) prefetch.append(backward_prefetch) # make queryset - objects = relation_chain[index].objects.filter( - **filter_params - ).prefetch_related( - *prefetch - ) + objects = relation_chain[index].objects.filter(**filter_params).prefetch_related(*prefetch) # list of objects which will be deleted with current object objects_to_delete = list(objects) @@ -55,9 +51,11 @@ def _prepare_objects_to_delete(object_to_delete): return objects_to_delete + def cache_deleted(method): def wrap(self, *args, **kwargs): from .signals import resource_delete + objects = _prepare_objects_to_delete(self) try: for obj in objects: @@ -66,6 +64,7 @@ def wrap(self, *args, **kwargs): method(self, *args, **kwargs) finally: clear_cache() + return wrap @@ -74,8 +73,10 @@ def get_end_timestamp(event: dict) -> datetime.datetime: return event["timestamp"] + datetime.timedelta(milliseconds=event["duration"]) return event["timestamp"] + def is_contained(event1: dict, event2: dict) -> bool: - return event1['timestamp'] < get_end_timestamp(event2) + return event1["timestamp"] < get_end_timestamp(event2) + def compute_working_time_per_ids(data: dict) -> dict: def read_ids(event: dict) -> tuple[int | None, int | None, int | None]: diff --git a/cvat/apps/events/views.py b/cvat/apps/events/views.py index ea1f967f81ed..e910dabdc3be 100644 --- a/cvat/apps/events/views.py +++ b/cvat/apps/events/views.py @@ -21,59 +21,114 @@ class EventsViewSet(viewsets.ViewSet): serializer_class = None - @extend_schema(summary='Log client events', - methods=['POST'], - description='Sends logs to the Clickhouse if it is connected', + @extend_schema( + summary="Log client events", + methods=["POST"], + description="Sends logs to the Clickhouse if it is connected", parameters=ORGANIZATION_OPEN_API_PARAMETERS, request=ClientEventsSerializer(), responses={ - '201': ClientEventsSerializer(), - }) + "201": ClientEventsSerializer(), + }, + ) def create(self, request): serializer = ClientEventsSerializer(data=request.data, context={"request": request}) serializer.is_valid(raise_exception=True) handle_client_events_push(request, serializer.validated_data) for event in serializer.validated_data["events"]: - message = JSONRenderer().render({ - **event, - 'timestamp': str(event["timestamp"].timestamp()) - }).decode('UTF-8') + message = ( + JSONRenderer() + .render({**event, "timestamp": str(event["timestamp"].timestamp())}) + .decode("UTF-8") + ) vlogger.info(message) return Response(serializer.validated_data, status=status.HTTP_201_CREATED) - @extend_schema(summary='Get an event log', - methods=['GET'], - description='The log is returned in the CSV format.', + @extend_schema( + summary="Get an event log", + methods=["GET"], + description="The log is returned in the CSV format.", parameters=[ - OpenApiParameter('org_id', location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, required=False, - description="Filter events by organization ID"), - OpenApiParameter('project_id', location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, required=False, - description="Filter events by project ID"), - OpenApiParameter('task_id', location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, required=False, - description="Filter events by task ID"), - OpenApiParameter('job_id', location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, required=False, - description="Filter events by job ID"), - OpenApiParameter('user_id', location=OpenApiParameter.QUERY, type=OpenApiTypes.INT, required=False, - description="Filter events by user ID"), - OpenApiParameter('from', location=OpenApiParameter.QUERY, type=OpenApiTypes.DATETIME, required=False, - description="Filter events after the datetime. If no 'from' or 'to' parameters are passed, the last 30 days will be set."), - OpenApiParameter('to', location=OpenApiParameter.QUERY, type=OpenApiTypes.DATETIME, required=False, - description="Filter events before the datetime. If no 'from' or 'to' parameters are passed, the last 30 days will be set."), - OpenApiParameter('filename', description='Desired output file name', - location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False), - OpenApiParameter('action', location=OpenApiParameter.QUERY, - description='Used to start downloading process after annotation file had been created', - type=OpenApiTypes.STR, required=False, enum=['download']), - OpenApiParameter('query_id', location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False, - description="ID of query request that need to check or download"), + OpenApiParameter( + "org_id", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.INT, + required=False, + description="Filter events by organization ID", + ), + OpenApiParameter( + "project_id", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.INT, + required=False, + description="Filter events by project ID", + ), + OpenApiParameter( + "task_id", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.INT, + required=False, + description="Filter events by task ID", + ), + OpenApiParameter( + "job_id", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.INT, + required=False, + description="Filter events by job ID", + ), + OpenApiParameter( + "user_id", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.INT, + required=False, + description="Filter events by user ID", + ), + OpenApiParameter( + "from", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.DATETIME, + required=False, + description="Filter events after the datetime. If no 'from' or 'to' parameters are passed, the last 30 days will be set.", + ), + OpenApiParameter( + "to", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.DATETIME, + required=False, + description="Filter events before the datetime. If no 'from' or 'to' parameters are passed, the last 30 days will be set.", + ), + OpenApiParameter( + "filename", + description="Desired output file name", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.STR, + required=False, + ), + OpenApiParameter( + "action", + location=OpenApiParameter.QUERY, + description="Used to start downloading process after annotation file had been created", + type=OpenApiTypes.STR, + required=False, + enum=["download"], + ), + OpenApiParameter( + "query_id", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.STR, + required=False, + description="ID of query request that need to check or download", + ), ], responses={ - '200': OpenApiResponse(description='Download of file started'), - '201': OpenApiResponse(description='CSV log file is ready for downloading'), - '202': OpenApiResponse(description='Creating a CSV log file has been started'), - }) + "200": OpenApiResponse(description="Download of file started"), + "201": OpenApiResponse(description="CSV log file is ready for downloading"), + "202": OpenApiResponse(description="Creating a CSV log file has been started"), + }, + ) def list(self, request): perm = EventsPermission.create_scope_list(request) filter_query = perm.filter(request.query_params) diff --git a/cvat/apps/health/apps.py b/cvat/apps/health/apps.py index e81a24ae6374..ae38010ff7b2 100644 --- a/cvat/apps/health/apps.py +++ b/cvat/apps/health/apps.py @@ -7,8 +7,9 @@ class HealthConfig(AppConfig): - name = 'cvat.apps.health' + name = "cvat.apps.health" def ready(self): from .backends import OPAHealthCheck + plugin_dir.register(OPAHealthCheck) diff --git a/cvat/apps/health/backends.py b/cvat/apps/health/backends.py index 48484c175df0..0ba37cb23195 100644 --- a/cvat/apps/health/backends.py +++ b/cvat/apps/health/backends.py @@ -14,7 +14,7 @@ class OPAHealthCheck(BaseHealthCheckBackend): critical_service = True def check_status(self): - opa_health_url = f'{settings.IAM_OPA_HOST}/health?bundles' + opa_health_url = f"{settings.IAM_OPA_HOST}/health?bundles" try: with make_requests_session() as session: response = session.get(opa_health_url) diff --git a/cvat/apps/health/management/commands/workerprobe.py b/cvat/apps/health/management/commands/workerprobe.py index 4cf6bb58d9f2..af9d663a1a29 100644 --- a/cvat/apps/health/management/commands/workerprobe.py +++ b/cvat/apps/health/management/commands/workerprobe.py @@ -21,13 +21,21 @@ def handle(self, *args, **options): raise CommandError(f"Queue {queue_name} is not defined") connection = django_rq.get_connection(queue_name) - workers = [w for w in Worker.all(connection) if queue_name in w.queue_names() and w.hostname == hostname] + workers = [ + w + for w in Worker.all(connection) + if queue_name in w.queue_names() and w.hostname == hostname + ] expected_workers = int(os.getenv("NUMPROCS", 1)) if len(workers) != expected_workers: - raise CommandError("Number of registered workers does not match the expected number, " \ - f"actual: {len(workers)}, expected: {expected_workers}") + raise CommandError( + "Number of registered workers does not match the expected number, " + f"actual: {len(workers)}, expected: {expected_workers}" + ) for worker in workers: if datetime.now() - worker.last_heartbeat > timedelta(seconds=worker.worker_ttl): - raise CommandError(f"It seems that worker {worker.name}, pid: {worker.pid} is dead") + raise CommandError( + f"It seems that worker {worker.name}, pid: {worker.pid} is dead" + ) diff --git a/cvat/apps/iam/admin.py b/cvat/apps/iam/admin.py index 849b76077d14..bf6efafe9a34 100644 --- a/cvat/apps/iam/admin.py +++ b/cvat/apps/iam/admin.py @@ -14,20 +14,27 @@ class ProfileInline(admin.StackedInline): model = Profile - fieldsets = ( - (None, {'fields': ('has_analytics_access', )}), - ) + fieldsets = ((None, {"fields": ("has_analytics_access",)}),) class CustomUserAdmin(UserAdmin): inlines = (ProfileInline,) list_display = ("username", "email", "first_name", "last_name", "is_active", "is_staff") fieldsets = ( - (None, {'fields': ('username', 'password')}), - (_('Personal info'), {'fields': ('first_name', 'last_name', 'email')}), - (_('Permissions'), {'fields': ('is_active', 'is_staff', 'is_superuser', - 'groups',)}), - (_('Important dates'), {'fields': ('last_login', 'date_joined')}), + (None, {"fields": ("username", "password")}), + (_("Personal info"), {"fields": ("first_name", "last_name", "email")}), + ( + _("Permissions"), + { + "fields": ( + "is_active", + "is_staff", + "is_superuser", + "groups", + ) + }, + ), + (_("Important dates"), {"fields": ("last_login", "date_joined")}), ) add_fieldsets = ( ( @@ -40,21 +47,17 @@ class CustomUserAdmin(UserAdmin): ) actions = ["user_activate", "user_deactivate"] - @admin.action( - permissions=["change"], description=_("Mark selected users as active") - ) + @admin.action(permissions=["change"], description=_("Mark selected users as active")) def user_activate(self, request, queryset): queryset.update(is_active=True) - @admin.action( - permissions=["change"], description=_("Mark selected users as not active") - ) + @admin.action(permissions=["change"], description=_("Mark selected users as not active")) def user_deactivate(self, request, queryset): queryset.update(is_active=False) class CustomGroupAdmin(GroupAdmin): - fieldsets = ((None, {'fields': ('name',)}),) + fieldsets = ((None, {"fields": ("name",)}),) admin.site.unregister(User) diff --git a/cvat/apps/iam/apps.py b/cvat/apps/iam/apps.py index e89e959256ee..00f051a75c08 100644 --- a/cvat/apps/iam/apps.py +++ b/cvat/apps/iam/apps.py @@ -7,8 +7,9 @@ class IAMConfig(AppConfig): - name = 'cvat.apps.iam' + name = "cvat.apps.iam" def ready(self): from .signals import register_signals + register_signals(self) diff --git a/cvat/apps/iam/authentication.py b/cvat/apps/iam/authentication.py index 79ee8e0671b0..74ec6f5424b7 100644 --- a/cvat/apps/iam/authentication.py +++ b/cvat/apps/iam/authentication.py @@ -13,12 +13,12 @@ # Got implementation ideas in https://github.com/marcgibbons/drf_signed_auth class Signer: - QUERY_PARAM = 'sign' + QUERY_PARAM = "sign" MAX_AGE = 30 @classmethod def get_salt(cls, url): - normalized_url = furl(url).remove(cls.QUERY_PARAM).url.encode('utf-8') + normalized_url = furl(url).remove(cls.QUERY_PARAM).url.encode("utf-8") salt = hashlib.sha256(normalized_url).hexdigest() return salt @@ -26,10 +26,7 @@ def sign(self, user, url): """ Create a signature for a user object. """ - data = { - 'user_id': user.pk, - 'username': user.get_username() - } + data = {"user_id": user.pk, "username": user.get_username()} return signing.dumps(data, salt=self.get_salt(url)) @@ -38,24 +35,24 @@ def unsign(self, signature, url): Return a user object for a valid signature. """ User = get_user_model() - data = signing.loads(signature, salt=self.get_salt(url), - max_age=self.MAX_AGE) + data = signing.loads(signature, salt=self.get_salt(url), max_age=self.MAX_AGE) if not isinstance(data, dict): raise signing.BadSignature() try: - return User.objects.get(**{ - 'pk': data.get('user_id'), - User.USERNAME_FIELD: data.get('username') - }) + return User.objects.get( + **{"pk": data.get("user_id"), User.USERNAME_FIELD: data.get("username")} + ) except User.DoesNotExist: raise signing.BadSignature() + class SignatureAuthentication(BaseAuthentication): """ Authentication backend for signed URLs. """ + def authenticate(self, request): """ Returns authenticated user if URL signature is valid. @@ -68,10 +65,10 @@ def authenticate(self, request): try: user = signer.unsign(sign, request.build_absolute_uri()) except signing.SignatureExpired: - raise exceptions.AuthenticationFailed('This URL has expired.') + raise exceptions.AuthenticationFailed("This URL has expired.") except signing.BadSignature: - raise exceptions.AuthenticationFailed('Invalid signature.') + raise exceptions.AuthenticationFailed("Invalid signature.") if not user.is_active: - raise exceptions.AuthenticationFailed('User inactive or deleted.') + raise exceptions.AuthenticationFailed("User inactive or deleted.") return (user, None) diff --git a/cvat/apps/iam/filters.py b/cvat/apps/iam/filters.py index 73a6268ba426..c99da171bac7 100644 --- a/cvat/apps/iam/filters.py +++ b/cvat/apps/iam/filters.py @@ -10,21 +10,21 @@ ORGANIZATION_OPEN_API_PARAMETERS = [ OpenApiParameter( - name='org', + name="org", type=str, required=False, location=OpenApiParameter.QUERY, description="Organization unique slug", ), OpenApiParameter( - name='org_id', + name="org_id", type=int, required=False, location=OpenApiParameter.QUERY, description="Organization identifier", ), OpenApiParameter( - name='X-Organization', + name="X-Organization", type=str, required=False, location=OpenApiParameter.HEADER, @@ -32,13 +32,14 @@ ), ] + class OrganizationFilterBackend(BaseFilterBackend): def _parameter_is_provided(self, request): for parameter in ORGANIZATION_OPEN_API_PARAMETERS: - if parameter.location == 'header' and parameter.name in request.headers: + if parameter.location == "header" and parameter.name in request.headers: return True - elif parameter.location == 'query' and parameter.name in request.query_params: + elif parameter.location == "query" and parameter.name in request.query_params: return True return False @@ -62,34 +63,35 @@ def _construct_filter_query(self, organization_fields, org_id): return Q() - def filter_queryset(self, request, queryset, view): # Filter works only for "list" requests and allows to return # only non-organization objects if org isn't specified if ( - view.detail or not view.iam_organization_field or + view.detail + or not view.iam_organization_field + or # FIXME: It should be handled in another way. For example, if we try to get information for a specific job # and org isn't specified, we need to return the full list of labels, issues, comments. # Allow crowdsourcing users to get labels/issues/comments related to specific job. # Crowdsourcing user always has worker group and isn't a member of an organization. ( - view.__class__.__name__ in ('LabelViewSet', 'IssueViewSet', 'CommentViewSet') and - request.query_params.get('job_id') and - request.iam_context.get('organization') is None and - request.iam_context.get('privilege') == 'worker' + view.__class__.__name__ in ("LabelViewSet", "IssueViewSet", "CommentViewSet") + and request.query_params.get("job_id") + and request.iam_context.get("organization") is None + and request.iam_context.get("privilege") == "worker" ) ): return queryset visibility = None - org = request.iam_context['organization'] + org = request.iam_context["organization"] if org: - visibility = {'organization': org.id} + visibility = {"organization": org.id} elif not org and self._parameter_is_provided(request): - visibility = {'organization': None} + visibility = {"organization": None} if visibility: org_id = visibility.pop("organization") @@ -108,15 +110,17 @@ def get_schema_operation_parameters(self, view): parameter_type = None if parameter.type == int: - parameter_type = 'integer' + parameter_type = "integer" elif parameter.type == str: - parameter_type = 'string' - - parameters.append({ - 'name': parameter.name, - 'in': parameter.location, - 'description': parameter.description, - 'schema': {'type': parameter_type} - }) + parameter_type = "string" + + parameters.append( + { + "name": parameter.name, + "in": parameter.location, + "description": parameter.description, + "schema": {"type": parameter_type}, + } + ) return parameters diff --git a/cvat/apps/iam/forms.py b/cvat/apps/iam/forms.py index d46f35cc4e9e..af619a563f38 100644 --- a/cvat/apps/iam/forms.py +++ b/cvat/apps/iam/forms.py @@ -11,12 +11,18 @@ UserModel = get_user_model() -class ResetPasswordFormEx(AllAuthPasswordResetForm): - def save(self, request=None, domain_override=None, - email_template_prefix='authentication/password_reset_key', - use_https=False, token_generator=default_token_generator, - extra_email_context=None, **kwargs): +class ResetPasswordFormEx(AllAuthPasswordResetForm): + def save( + self, + request=None, + domain_override=None, + email_template_prefix="authentication/password_reset_key", + use_https=False, + token_generator=default_token_generator, + extra_email_context=None, + **kwargs, + ): """ Generate a one-use only link for resetting password and send it to the user. @@ -32,16 +38,16 @@ def save(self, request=None, domain_override=None, for user in self.users: user_email = getattr(user, email_field_name) context = { - 'email': user_email, - 'domain': domain, - 'site_name': site_name, - 'uid': user_pk_to_url_str(user), - 'user': user, - 'token': token_generator.make_token(user), - 'protocol': 'https' if use_https else 'http', + "email": user_email, + "domain": domain, + "site_name": site_name, + "uid": user_pk_to_url_str(user), + "user": user, + "token": token_generator.make_token(user), + "protocol": "https" if use_https else "http", **(extra_email_context or {}), } get_adapter(request).send_mail(email_template_prefix, email, context) - return self.cleaned_data['email'] + return self.cleaned_data["email"] diff --git a/cvat/apps/iam/middleware.py b/cvat/apps/iam/middleware.py index d96a8e364086..c09c5eeb96b6 100644 --- a/cvat/apps/iam/middleware.py +++ b/cvat/apps/iam/middleware.py @@ -22,31 +22,32 @@ def get_organization(request): organization = None try: - org_slug = request.GET.get('org') - org_id = request.GET.get('org_id') - org_header = request.headers.get('X-Organization') + org_slug = request.GET.get("org") + org_id = request.GET.get("org_id") + org_header = request.headers.get("X-Organization") if org_id is not None and (org_slug is not None or org_header is not None): - raise ValidationError('You cannot specify "org_id" query parameter with ' - '"org" query parameter or "X-Organization" HTTP header at the same time.') + raise ValidationError( + 'You cannot specify "org_id" query parameter with ' + '"org" query parameter or "X-Organization" HTTP header at the same time.' + ) if org_slug is not None and org_header is not None and org_slug != org_header: - raise ValidationError('You cannot specify "org" query parameter and ' - '"X-Organization" HTTP header with different values.') + raise ValidationError( + 'You cannot specify "org" query parameter and ' + '"X-Organization" HTTP header with different values.' + ) org_slug = org_slug if org_slug is not None else org_header if org_slug: - organization = Organization.objects.select_related('owner').get(slug=org_slug) + organization = Organization.objects.select_related("owner").get(slug=org_slug) elif org_id: - organization = Organization.objects.select_related('owner').get(id=int(org_id)) + organization = Organization.objects.select_related("owner").get(id=int(org_id)) except Organization.DoesNotExist: - raise NotFound(f'{org_slug or org_id} organization does not exist.') + raise NotFound(f"{org_slug or org_id} organization does not exist.") - context = { - "organization": organization, - "privilege": getattr(privilege, 'name', None) - } + context = {"organization": organization, "privilege": getattr(privilege, "name", None)} return context @@ -62,6 +63,7 @@ def __call__(self, request): return self.get_response(request) + class SessionRefreshMiddleware: """ Implements behavior similar to SESSION_SAVE_EVERY_REQUEST=True, but instead of diff --git a/cvat/apps/iam/migrations/0001_remove_business_group.py b/cvat/apps/iam/migrations/0001_remove_business_group.py index a37ca5a9e3c9..aa64d4a56d6d 100644 --- a/cvat/apps/iam/migrations/0001_remove_business_group.py +++ b/cvat/apps/iam/migrations/0001_remove_business_group.py @@ -7,7 +7,7 @@ def delete_business_group(apps, schema_editor): - Group = apps.get_model('auth', 'Group') + Group = apps.get_model("auth", "Group") User = apps.get_model(settings.AUTH_USER_MODEL) if user_group := Group.objects.filter(name=USER_GROUP_NAME).first(): diff --git a/cvat/apps/iam/models.py b/cvat/apps/iam/models.py index b1220197cf2a..f7c3408e3d12 100644 --- a/cvat/apps/iam/models.py +++ b/cvat/apps/iam/models.py @@ -1,4 +1,3 @@ # Copyright (C) 2021-2022 Intel Corporation # # SPDX-License-Identifier: MIT - diff --git a/cvat/apps/iam/permissions.py b/cvat/apps/iam/permissions.py index d4925426724a..f13d6be377ce 100644 --- a/cvat/apps/iam/permissions.py +++ b/cvat/apps/iam/permissions.py @@ -44,21 +44,21 @@ def get_organization(request, obj): if obj: try: - organization_id = getattr(obj, 'organization_id') + organization_id = getattr(obj, "organization_id") except AttributeError as exc: # Skip initialization of organization for those objects that don't related with organization - view = request.parser_context.get('view') + view = request.parser_context.get("view") if view and view.basename in settings.OBJECTS_NOT_RELATED_WITH_ORG: - return request.iam_context['organization'] + return request.iam_context["organization"] raise exc try: - return Organization.objects.select_related('owner').get(id=organization_id) + return Organization.objects.select_related("owner").get(id=organization_id) except Organization.DoesNotExist: return None - return request.iam_context['organization'] + return request.iam_context["organization"] def get_membership(request, organization): @@ -66,21 +66,20 @@ def get_membership(request, organization): return None return Membership.objects.filter( - organization=organization, - user=request.user, - is_active=True + organization=organization, user=request.user, is_active=True ).first() -def build_iam_context(request, organization: Optional[Organization], membership: Optional[Membership]): +def build_iam_context( + request, organization: Optional[Organization], membership: Optional[Membership] +): return { - 'user_id': request.user.id, - 'group_name': request.iam_context['privilege'], - 'org_id': getattr(organization, 'id', None), - 'org_slug': getattr(organization, 'slug', None), - 'org_owner_id': getattr(organization.owner, 'id', None) - if organization else None, - 'org_role': getattr(membership, 'role', None), + "user_id": request.user.id, + "group_name": request.iam_context["privilege"], + "org_id": getattr(organization, "id", None), + "org_slug": getattr(organization, "slug", None), + "org_owner_id": getattr(organization.owner, "id", None) if organization else None, + "org_role": getattr(membership, "role", None), } @@ -103,23 +102,19 @@ class OpenPolicyAgentPermission(metaclass=ABCMeta): @classmethod @abstractmethod - def create(cls, request, view, obj, iam_context) -> Sequence[OpenPolicyAgentPermission]: - ... + def create(cls, request, view, obj, iam_context) -> Sequence[OpenPolicyAgentPermission]: ... @classmethod def create_base_perm(cls, request, view, scope, iam_context, obj=None, **kwargs): if not iam_context and request: iam_context = get_iam_context(request, obj) - return cls( - scope=scope, - obj=obj, - **iam_context, **kwargs) + return cls(scope=scope, obj=obj, **iam_context, **kwargs) @classmethod def create_scope_list(cls, request, iam_context=None): if not iam_context and request: iam_context = get_iam_context(request, None) - return cls(**iam_context, scope='list') + return cls(**iam_context, scope="list") def __init__(self, **kwargs): self.obj = None @@ -127,27 +122,31 @@ def __init__(self, **kwargs): setattr(self, name, val) self.payload = { - 'input': { - 'scope': self.scope, - 'auth': { - 'user': { - 'id': self.user_id, - 'privilege': self.group_name, + "input": { + "scope": self.scope, + "auth": { + "user": { + "id": self.user_id, + "privilege": self.group_name, }, - 'organization': { - 'id': self.org_id, - 'owner': { - 'id': self.org_owner_id, - }, - 'user': { - 'role': self.org_role, - }, - } if self.org_id is not None else None - } + "organization": ( + { + "id": self.org_id, + "owner": { + "id": self.org_owner_id, + }, + "user": { + "role": self.org_role, + }, + } + if self.org_id is not None + else None + ), + }, } } - self.payload['input']['resource'] = self.get_resource() + self.payload["input"]["resource"] = self.get_resource() @abstractmethod def get_resource(self): @@ -156,13 +155,13 @@ def get_resource(self): def check_access(self) -> PermissionResult: with make_requests_session() as session: response = session.post(self.url, json=self.payload) - output = response.json()['result'] + output = response.json()["result"] allow = False reasons = [] if isinstance(output, dict): - allow = output['allow'] - reasons = output.get('reasons', []) + allow = output["allow"] + reasons = output.get("reasons", []) elif isinstance(output, bool): allow = output else: @@ -171,21 +170,21 @@ def check_access(self) -> PermissionResult: return PermissionResult(allow=allow, reasons=reasons) def filter(self, queryset): - url = self.url.replace('/allow', '/filter') + url = self.url.replace("/allow", "/filter") with make_requests_session() as session: - r = session.post(url, json=self.payload).json()['result'] + r = session.post(url, json=self.payload).json()["result"] q_objects = [] ops_dict = { - '|': operator.or_, - '&': operator.and_, - '~': operator.not_, + "|": operator.or_, + "&": operator.and_, + "~": operator.not_, } for item in r: if isinstance(item, str): val1 = q_objects.pop() - if item == '~': + if item == "~": q_objects.append(ops_dict[item](val1)) else: val2 = q_objects.pop() @@ -211,7 +210,7 @@ def get_per_field_update_scopes(cls, request, scopes_per_field): request body fields are associated with different scopes. """ - assert request.method == 'PATCH' + assert request.method == "PATCH" # Even if no fields are modified, a PATCH request typically returns the # new state of the object, so we need to make sure the user has permissions @@ -226,7 +225,7 @@ def get_per_field_update_scopes(cls, request, scopes_per_field): return scopes -T = TypeVar('T', bound=Model) +T = TypeVar("T", bound=Model) def is_public_obj(obj: T) -> bool: @@ -257,22 +256,23 @@ def has_permission(self, request, view): if not view.detail: return self.check_permission(request, view, None) else: - return True # has_object_permission will be called later + return True # has_object_permission will be called later def has_object_permission(self, request, view, obj): return self.check_permission(request, view, obj) @staticmethod def is_metadata_request(request, view): - return request.method == 'OPTIONS' \ - or (request.method == 'POST' and view.action == 'metadata' and len(request.data) == 0) + return request.method == "OPTIONS" or ( + request.method == "POST" and view.action == "metadata" and len(request.data) == 0 + ) class IsAuthenticatedOrReadPublicResource(BasePermission): def has_object_permission(self, request, view, obj) -> bool: return bool( - (request.user and request.user.is_authenticated) or - (request.method == 'GET' and is_public_obj(obj)) + (request.user and request.user.is_authenticated) + or (request.method == "GET" and is_public_obj(obj)) ) diff --git a/cvat/apps/iam/rules/tests/generate_tests.py b/cvat/apps/iam/rules/tests/generate_tests.py index 0bc788300982..92b4a0e699a9 100755 --- a/cvat/apps/iam/rules/tests/generate_tests.py +++ b/cvat/apps/iam/rules/tests/generate_tests.py @@ -15,6 +15,7 @@ REPO_ROOT = Path(__file__).resolve().parents[5] + def create_arg_parser() -> ArgumentParser: parser = ArgumentParser(add_help=True) parser.add_argument( @@ -36,7 +37,7 @@ def parse_args(args: Optional[Sequence[str]] = None) -> Namespace: def call_generator(generator_path: Path, gen_params: Namespace) -> None: rules_dir = generator_path.parents[2] subprocess.check_call( - [sys.executable, generator_path.relative_to(rules_dir), 'tests/configs'], cwd=rules_dir + [sys.executable, generator_path.relative_to(rules_dir), "tests/configs"], cwd=rules_dir ) @@ -53,7 +54,7 @@ def main(args: Optional[Sequence[str]] = None) -> int: partial(call_generator, gen_params=args), generator_paths, ): - pass # consume all results in order to propagate exceptions + pass # consume all results in order to propagate exceptions if __name__ == "__main__": diff --git a/cvat/apps/iam/schema.py b/cvat/apps/iam/schema.py index a09bac4817a1..7f54c5597a95 100644 --- a/cvat/apps/iam/schema.py +++ b/cvat/apps/iam/schema.py @@ -17,35 +17,37 @@ class SignatureAuthenticationScheme(OpenApiAuthenticationExtension): Adds the signature auth method to schema """ - target_class = 'cvat.apps.iam.authentication.SignatureAuthentication' - name = 'signatureAuth' # name used in the schema + target_class = "cvat.apps.iam.authentication.SignatureAuthentication" + name = "signatureAuth" # name used in the schema def get_security_definition(self, auto_schema): return { - 'type': 'apiKey', - 'in': 'query', - 'name': 'sign', - 'description': 'Can be used to share URLs to private links', + "type": "apiKey", + "in": "query", + "name": "sign", + "description": "Can be used to share URLs to private links", } + class TokenAuthenticationScheme(TokenScheme): """ Adds the token auth method to schema. The description includes extra info comparing to what is generated by default. """ - name = 'tokenAuth' + name = "tokenAuth" priority = 0 match_subclasses = True def get_security_requirement(self, auto_schema): # These schemes must be used together - return {'sessionAuth': [], 'csrfAuth': [], self.name: []} + return {"sessionAuth": [], "csrfAuth": [], self.name: []} def get_security_definition(self, auto_schema): schema = super().get_security_definition(auto_schema) - schema['x-token-prefix'] = self.target.keyword - schema['description'] = textwrap.dedent(f""" + schema["x-token-prefix"] = self.target.keyword + schema["description"] = textwrap.dedent( + f""" To authenticate using a token (or API key), you need to have 3 components in a request: - the 'sessionid' cookie - the 'csrftoken' cookie or 'X-CSRFTOKEN' header @@ -53,16 +55,18 @@ def get_security_definition(self, auto_schema): You can obtain an API key (the token) from the server response on the basic auth request. - """) + """ + ) return schema + class CookieAuthenticationScheme(SessionScheme): """ This class adds csrftoken cookie into security sections. It must be used together with the 'sessionid' cookie. """ - name = ['sessionAuth', 'csrfAuth'] + name = ["sessionAuth", "csrfAuth"] priority = 0 def get_security_requirement(self, auto_schema): @@ -72,13 +76,14 @@ def get_security_requirement(self, auto_schema): def get_security_definition(self, auto_schema): sessionid_schema = super().get_security_definition(auto_schema) csrftoken_schema = { - 'type': 'apiKey', - 'in': 'cookie', - 'name': 'csrftoken', - 'description': 'Can be sent as a cookie or as the X-CSRFTOKEN header' + "type": "apiKey", + "in": "cookie", + "name": "csrftoken", + "description": "Can be sent as a cookie or as the X-CSRFTOKEN header", } return [sessionid_schema, csrftoken_schema] + class CustomAutoSchema(AutoSchema): def get_operation_id(self): # Change style of operation ids to [viewset _ action _ object] @@ -86,20 +91,20 @@ def get_operation_id(self): tokenized_path = self._tokenize_path() # replace dashes as they can be problematic later in code generation - tokenized_path = [t.replace('-', '_') for t in tokenized_path] + tokenized_path = [t.replace("-", "_") for t in tokenized_path] - if self.method == 'GET' and self._is_list_view(): - action = 'list' + if self.method == "GET" and self._is_list_view(): + action = "list" else: action = self.method_mapping[self.method.lower()] if not tokenized_path: - tokenized_path.append('root') + tokenized_path.append("root") - if re.search(r'', self.path_regex): - tokenized_path.append('formatted') + if re.search(r"", self.path_regex): + tokenized_path.append("formatted") - return '_'.join([tokenized_path[0]] + [action] + tokenized_path[1:]) + return "_".join([tokenized_path[0]] + [action] + tokenized_path[1:]) def _get_request_for_media_type(self, serializer, *args, **kwargs): # Enables support for required=False serializers in request body specification diff --git a/cvat/apps/iam/serializers.py b/cvat/apps/iam/serializers.py index 3ff5e01497c5..7de9919e3ab3 100644 --- a/cvat/apps/iam/serializers.py +++ b/cvat/apps/iam/serializers.py @@ -31,22 +31,30 @@ class RegisterSerializerEx(RegisterSerializer): @extend_schema_field(serializers.BooleanField) def get_email_verification_required(self, obj: Union[dict, User]) -> bool: - return allauth_settings.EMAIL_VERIFICATION == allauth_settings.EmailVerificationMethod.MANDATORY + return ( + allauth_settings.EMAIL_VERIFICATION + == allauth_settings.EmailVerificationMethod.MANDATORY + ) @extend_schema_field(serializers.CharField(allow_null=True)) def get_key(self, obj: Union[dict, User]) -> Optional[str]: key = None - if isinstance(obj, User) and allauth_settings.EMAIL_VERIFICATION != \ - allauth_settings.EmailVerificationMethod.MANDATORY: + if ( + isinstance(obj, User) + and allauth_settings.EMAIL_VERIFICATION + != allauth_settings.EmailVerificationMethod.MANDATORY + ): key = obj.auth_token.key return key def get_cleaned_data(self): data = super().get_cleaned_data() - data.update({ - 'first_name': self.validated_data.get('first_name', ''), - 'last_name': self.validated_data.get('last_name', ''), - }) + data.update( + { + "first_name": self.validated_data.get("first_name", ""), + "last_name": self.validated_data.get("last_name", ""), + } + ) return data @@ -55,7 +63,7 @@ def email_address_exists(email) -> bool: if EmailAddress.objects.filter(email__iexact=email).exists(): return True - if (email_field := allauth_settings.USER_MODEL_EMAIL_FIELD): + if email_field := allauth_settings.USER_MODEL_EMAIL_FIELD: users = get_user_model().objects return users.filter(**{email_field + "__iexact": email}).exists() return False @@ -66,7 +74,7 @@ def email_address_exists(email) -> bool: user = get_dummy_user(email) if not user: raise serializers.ValidationError( - ('A user is already registered with this e-mail address.'), + ("A user is already registered with this e-mail address."), ) return email @@ -82,11 +90,9 @@ def save(self, request): user = adapter.save_user(request, user, self, commit=False) if "password1" in self.cleaned_data: try: - adapter.clean_password(self.cleaned_data['password1'], user=user) + adapter.clean_password(self.cleaned_data["password1"], user=user) except DjangoValidationError as exc: - raise serializers.ValidationError( - detail=serializers.as_serializer_error(exc) - ) + raise serializers.ValidationError(detail=serializers.as_serializer_error(exc)) user.save() self.custom_signup(request, user) @@ -102,35 +108,42 @@ def password_reset_form_class(self): def get_email_options(self): domain = None - if hasattr(settings, 'UI_HOST') and settings.UI_HOST: + if hasattr(settings, "UI_HOST") and settings.UI_HOST: domain = settings.UI_HOST - if hasattr(settings, 'UI_PORT') and settings.UI_PORT: - domain += ':{}'.format(settings.UI_PORT) - return { - 'domain_override': domain - } + if hasattr(settings, "UI_PORT") and settings.UI_PORT: + domain += ":{}".format(settings.UI_PORT) + return {"domain_override": domain} + class LoginSerializerEx(LoginSerializer): def get_auth_user_using_allauth(self, username, email, password): def is_email_authentication(): - return settings.ACCOUNT_AUTHENTICATION_METHOD == allauth_settings.AuthenticationMethod.EMAIL + return ( + settings.ACCOUNT_AUTHENTICATION_METHOD + == allauth_settings.AuthenticationMethod.EMAIL + ) def is_username_authentication(): - return settings.ACCOUNT_AUTHENTICATION_METHOD == allauth_settings.AuthenticationMethod.USERNAME + return ( + settings.ACCOUNT_AUTHENTICATION_METHOD + == allauth_settings.AuthenticationMethod.USERNAME + ) # check that the server settings match the request if is_username_authentication() and not username and email: raise ValidationError( - 'Attempt to authenticate with email/password. ' - 'But username/password are used for authentication on the server. ' - 'Please check your server configuration ACCOUNT_AUTHENTICATION_METHOD.') + "Attempt to authenticate with email/password. " + "But username/password are used for authentication on the server. " + "Please check your server configuration ACCOUNT_AUTHENTICATION_METHOD." + ) if is_email_authentication() and not email and username: raise ValidationError( - 'Attempt to authenticate with username/password. ' - 'But email/password are used for authentication on the server. ' - 'Please check your server configuration ACCOUNT_AUTHENTICATION_METHOD.') + "Attempt to authenticate with username/password. " + "But email/password are used for authentication on the server. " + "Please check your server configuration ACCOUNT_AUTHENTICATION_METHOD." + ) # Authentication through email if settings.ACCOUNT_AUTHENTICATION_METHOD == allauth_settings.AuthenticationMethod.EMAIL: @@ -144,6 +157,6 @@ def is_username_authentication(): if email: users = filter_users_by_email(email) if not users or len(users) > 1: - raise ValidationError('Unable to login with provided credentials') + raise ValidationError("Unable to login with provided credentials") return self._validate_username_email(username, email, password) diff --git a/cvat/apps/iam/signals.py b/cvat/apps/iam/signals.py index bce73de3dd92..b8bbf643dab8 100644 --- a/cvat/apps/iam/signals.py +++ b/cvat/apps/iam/signals.py @@ -12,7 +12,9 @@ def register_groups(sender, **kwargs): for role in settings.IAM_ROLES: Group.objects.get_or_create(name=role) -if settings.IAM_TYPE == 'BASIC': + +if settings.IAM_TYPE == "BASIC": + def create_user(sender, instance, created, **kwargs): from allauth.account import app_settings as allauth_settings from allauth.account.models import EmailAddress @@ -23,14 +25,16 @@ def create_user(sender, instance, created, **kwargs): # create and verify EmailAddress for superuser accounts if allauth_settings.EMAIL_REQUIRED: - EmailAddress.objects.get_or_create(user=instance, - email=instance.email, primary=True, verified=True) - else: # don't need to add default groups for superuser - if created and not getattr(instance, 'skip_group_assigning', None): + EmailAddress.objects.get_or_create( + user=instance, email=instance.email, primary=True, verified=True + ) + else: # don't need to add default groups for superuser + if created and not getattr(instance, "skip_group_assigning", None): db_group = Group.objects.get(name=settings.IAM_DEFAULT_ROLE) instance.groups.add(db_group) -elif settings.IAM_TYPE == 'LDAP': +elif settings.IAM_TYPE == "LDAP": + def create_user(sender, user=None, ldap_user=None, **kwargs): user_groups = [] for role in settings.IAM_ROLES: @@ -56,10 +60,10 @@ def create_user(sender, user=None, ldap_user=None, **kwargs): def register_signals(app_config): post_migrate.connect(register_groups, app_config) - if settings.IAM_TYPE == 'BASIC': + if settings.IAM_TYPE == "BASIC": # Add default groups and add admin rights to super users. post_save.connect(create_user, sender=User) - elif settings.IAM_TYPE == 'LDAP': + elif settings.IAM_TYPE == "LDAP": import django_auth_ldap.backend # Map groups from LDAP to roles, convert a user to super user if he/she diff --git a/cvat/apps/iam/tests/test_rest_api.py b/cvat/apps/iam/tests/test_rest_api.py index 764e7eec87eb..db0745e999d3 100644 --- a/cvat/apps/iam/tests/test_rest_api.py +++ b/cvat/apps/iam/tests/test_rest_api.py @@ -14,12 +14,19 @@ from cvat.apps.iam.views import ConfirmEmailViewEx urlpatterns = iam_url_patterns + [ - re_path(r'^account-confirm-email/(?P[-:\w]+)/$', ConfirmEmailViewEx.as_view(), - name='account_confirm_email'), - path('register/account-email-verification-sent', EmailVerificationSentView.as_view(), - name='account_email_verification_sent'), + re_path( + r"^account-confirm-email/(?P[-:\w]+)/$", + ConfirmEmailViewEx.as_view(), + name="account_confirm_email", + ), + path( + "register/account-email-verification-sent", + EmailVerificationSentView.as_view(), + name="account_email_verification_sent", + ), ] + class ForceLogin: def __init__(self, user, client): self.user = user @@ -27,7 +34,7 @@ def __init__(self, user, client): def __enter__(self): if self.user: - self.client.force_login(self.user, backend='django.contrib.auth.backends.ModelBackend') + self.client.force_login(self.user, backend="django.contrib.auth.backends.ModelBackend") return self @@ -35,57 +42,91 @@ def __exit__(self, exception_type, exception_value, traceback): if self.user: self.client.logout() + class UserRegisterAPITestCase(APITestCase): - user_data = {'first_name': 'test_first', 'last_name': 'test_last', 'username': 'test_username', - 'email': 'test_email@test.com', 'password1': '$Test357Test%', 'password2': '$Test357Test%', - 'confirmations': []} + user_data = { + "first_name": "test_first", + "last_name": "test_last", + "username": "test_username", + "email": "test_email@test.com", + "password1": "$Test357Test%", + "password2": "$Test357Test%", + "confirmations": [], + } def setUp(self): self.client = APIClient() def _run_api_v2_user_register(self, data): - url = reverse('rest_register') - response = self.client.post(url, data, format='json') + url = reverse("rest_register") + response = self.client.post(url, data, format="json") return response def _check_response(self, response, data): self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.data, data) - @override_settings(ACCOUNT_EMAIL_VERIFICATION='none') + @override_settings(ACCOUNT_EMAIL_VERIFICATION="none") def test_api_v2_user_register_with_email_verification_none(self): """ Ensure we can register a user and get auth token key when email verification is none """ response = self._run_api_v2_user_register(self.user_data) - user_token = Token.objects.get(user__username=response.data['username']) - self._check_response(response, {'first_name': 'test_first', 'last_name': 'test_last', - 'username': 'test_username', 'email': 'test_email@test.com', - 'email_verification_required': False, 'key': user_token.key}) + user_token = Token.objects.get(user__username=response.data["username"]) + self._check_response( + response, + { + "first_name": "test_first", + "last_name": "test_last", + "username": "test_username", + "email": "test_email@test.com", + "email_verification_required": False, + "key": user_token.key, + }, + ) # Since URLConf is executed before running the tests, so we have to manually configure the url patterns for # the tests and pass it using ROOT_URLCONF in the override settings decorator - @override_settings(ACCOUNT_EMAIL_VERIFICATION='optional', ROOT_URLCONF=__name__) + @override_settings(ACCOUNT_EMAIL_VERIFICATION="optional", ROOT_URLCONF=__name__) def test_api_v2_user_register_with_email_verification_optional(self): """ Ensure we can register a user and get auth token key when email verification is optional """ response = self._run_api_v2_user_register(self.user_data) - user_token = Token.objects.get(user__username=response.data['username']) - self._check_response(response, {'first_name': 'test_first', 'last_name': 'test_last', - 'username': 'test_username', 'email': 'test_email@test.com', - 'email_verification_required': False, 'key': user_token.key}) - - @override_settings(ACCOUNT_EMAIL_REQUIRED=True, ACCOUNT_EMAIL_VERIFICATION='mandatory', - EMAIL_BACKEND='django.core.mail.backends.console.EmailBackend', ROOT_URLCONF=__name__) + user_token = Token.objects.get(user__username=response.data["username"]) + self._check_response( + response, + { + "first_name": "test_first", + "last_name": "test_last", + "username": "test_username", + "email": "test_email@test.com", + "email_verification_required": False, + "key": user_token.key, + }, + ) + + @override_settings( + ACCOUNT_EMAIL_REQUIRED=True, + ACCOUNT_EMAIL_VERIFICATION="mandatory", + EMAIL_BACKEND="django.core.mail.backends.console.EmailBackend", + ROOT_URLCONF=__name__, + ) def test_register_account_with_email_verification_mandatory(self): """ Ensure we can register a user and it does not return auth token key when email verification is mandatory """ response = self._run_api_v2_user_register(self.user_data) - self._check_response(response, {'first_name': 'test_first', 'last_name': 'test_last', - 'username': 'test_username', 'email': 'test_email@test.com', - 'email_verification_required': True, 'key': None}) - + self._check_response( + response, + { + "first_name": "test_first", + "last_name": "test_last", + "username": "test_username", + "email": "test_email@test.com", + "email_verification_required": True, + "key": None, + }, + ) diff --git a/cvat/apps/iam/urls.py b/cvat/apps/iam/urls.py index c0b1dcbe7d86..8f66f48f22b1 100644 --- a/cvat/apps/iam/urls.py +++ b/cvat/apps/iam/urls.py @@ -22,33 +22,36 @@ SigningView, ) -BASIC_LOGIN_PATH_NAME = 'rest_login' -BASIC_REGISTER_PATH_NAME = 'rest_register' +BASIC_LOGIN_PATH_NAME = "rest_login" +BASIC_REGISTER_PATH_NAME = "rest_register" urlpatterns = [ - path('login', LoginViewEx.as_view(), name=BASIC_LOGIN_PATH_NAME), - path('logout', LogoutView.as_view(), name='rest_logout'), - path('signing', SigningView.as_view(), name='signing'), - path('rules', RulesView.as_view(), name='rules'), + path("login", LoginViewEx.as_view(), name=BASIC_LOGIN_PATH_NAME), + path("logout", LogoutView.as_view(), name="rest_logout"), + path("signing", SigningView.as_view(), name="signing"), + path("rules", RulesView.as_view(), name="rules"), ] -if settings.IAM_TYPE == 'BASIC': +if settings.IAM_TYPE == "BASIC": urlpatterns += [ - path('register', RegisterViewEx.as_view(), name=BASIC_REGISTER_PATH_NAME), + path("register", RegisterViewEx.as_view(), name=BASIC_REGISTER_PATH_NAME), # password - path('password/reset', PasswordResetView.as_view(), - name='rest_password_reset'), - path('password/reset/confirm', PasswordResetConfirmView.as_view(), - name='rest_password_reset_confirm'), - path('password/change', PasswordChangeView.as_view(), - name='rest_password_change'), + path("password/reset", PasswordResetView.as_view(), name="rest_password_reset"), + path( + "password/reset/confirm", + PasswordResetConfirmView.as_view(), + name="rest_password_reset_confirm", + ), + path("password/change", PasswordChangeView.as_view(), name="rest_password_change"), ] - if allauth_settings.EMAIL_VERIFICATION != \ - allauth_settings.EmailVerificationMethod.NONE: + if allauth_settings.EMAIL_VERIFICATION != allauth_settings.EmailVerificationMethod.NONE: # emails urlpatterns += [ - re_path(r'^account-confirm-email/(?P[-:\w]+)/$', ConfirmEmailViewEx.as_view(), - name='account_confirm_email'), + re_path( + r"^account-confirm-email/(?P[-:\w]+)/$", + ConfirmEmailViewEx.as_view(), + name="account_confirm_email", + ), ] -urlpatterns = [path('auth/', include(urlpatterns))] +urlpatterns = [path("auth/", include(urlpatterns))] diff --git a/cvat/apps/iam/utils.py b/cvat/apps/iam/utils.py index 5f5bee19352f..9b911e48ea7c 100644 --- a/cvat/apps/iam/utils.py +++ b/cvat/apps/iam/utils.py @@ -9,26 +9,29 @@ from django.contrib.sessions.backends.base import SessionBase _OPA_RULES_PATHS = { - Path(__file__).parent / 'rules', + Path(__file__).parent / "rules", } + @functools.lru_cache(maxsize=None) def get_opa_bundle() -> tuple[bytes, str]: bundle_file = io.BytesIO() - with tarfile.open(fileobj=bundle_file, mode='w:gz') as tar: + with tarfile.open(fileobj=bundle_file, mode="w:gz") as tar: for p in _OPA_RULES_PATHS: - for f in p.glob('*[!.gen].rego'): + for f in p.glob("*[!.gen].rego"): tar.add(name=f, arcname=f.relative_to(p.parent)) bundle = bundle_file.getvalue() etag = hashlib.blake2b(bundle).hexdigest() return bundle, etag + def add_opa_rules_path(path: Path) -> None: _OPA_RULES_PATHS.add(path) get_opa_bundle.cache_clear() + def get_dummy_user(email): from allauth.account import app_settings from allauth.account.models import EmailAddress @@ -40,13 +43,13 @@ def get_dummy_user(email): user = users[0] if user.has_usable_password(): return None - if app_settings.EMAIL_VERIFICATION == \ - app_settings.EmailVerificationMethod.MANDATORY: + if app_settings.EMAIL_VERIFICATION == app_settings.EmailVerificationMethod.MANDATORY: email = EmailAddress.objects.get_for_user(user, email) if email.verified: return None return user + def clean_up_sessions() -> None: SessionStore: type[SessionBase] = importlib.import_module(settings.SESSION_ENGINE).SessionStore SessionStore.clear_expired() diff --git a/cvat/apps/iam/views.py b/cvat/apps/iam/views.py index b17be7ac7cb3..d9bf960e426c 100644 --- a/cvat/apps/iam/views.py +++ b/cvat/apps/iam/views.py @@ -33,24 +33,27 @@ from .utils import get_opa_bundle -@extend_schema(tags=['auth']) -@extend_schema_view(post=extend_schema( - summary='This method signs URL for access to the server', - description='Signed URL contains a token which authenticates a user on the server.' - 'Signed URL is valid during 30 seconds since signing.', - request=inline_serializer( - name='Signing', - fields={ - 'url': serializers.CharField(), - } - ), - responses={'200': OpenApiResponse(response=OpenApiTypes.STR, description='text URL')})) +@extend_schema(tags=["auth"]) +@extend_schema_view( + post=extend_schema( + summary="This method signs URL for access to the server", + description="Signed URL contains a token which authenticates a user on the server." + "Signed URL is valid during 30 seconds since signing.", + request=inline_serializer( + name="Signing", + fields={ + "url": serializers.CharField(), + }, + ), + responses={"200": OpenApiResponse(response=OpenApiTypes.STR, description="text URL")}, + ) +) class SigningView(views.APIView): def post(self, request): - url = request.data.get('url') + url = request.data.get("url") if not url: - raise ValidationError('Please provide `url` parameter') + raise ValidationError("Please provide `url` parameter") signer = Signer() url = self.request.build_absolute_uri(url) @@ -59,6 +62,7 @@ def post(self, request): url = furl(url).add({Signer.QUERY_PARAM: sign}).url return Response(url) + class LoginViewEx(LoginView): """ Check the credentials and return the REST Token @@ -71,6 +75,7 @@ class LoginViewEx(LoginView): Accept the following POST parameters: username, email, password Return the REST Framework Token Object's key. """ + @extend_schema(responses=get_token_serializer_class()) def post(self, request, *args, **kwargs): self.request = request @@ -79,9 +84,9 @@ def post(self, request, *args, **kwargs): self.serializer.is_valid(raise_exception=True) except ValidationError: user = self.serializer.get_auth_user( - self.serializer.data.get('username'), - self.serializer.data.get('email'), - self.serializer.data.get('password') + self.serializer.data.get("username"), + self.serializer.data.get("email"), + self.serializer.data.get("password"), ) if not user: raise @@ -93,13 +98,14 @@ def post(self, request, *args, **kwargs): # we cannot use redirect to ACCOUNT_EMAIL_VERIFICATION_SENT_REDIRECT_URL here # because redirect will make a POST request and we'll get a 404 code # (although in the browser request method will be displayed like GET) - return HttpResponseBadRequest('Unverified email') - except Exception: # nosec + return HttpResponseBadRequest("Unverified email") + except Exception: # nosec pass self.login() return self.get_response() + class RegisterViewEx(RegisterView): def get_response_data(self, user): serializer = self.get_serializer(user) @@ -120,20 +126,24 @@ def get_response_data(self, user): # Link to the issue: https://github.com/iMerica/dj-rest-auth/issues/604 def perform_create(self, serializer): user = serializer.save(self.request) - if allauth_settings.EMAIL_VERIFICATION != \ - allauth_settings.EmailVerificationMethod.MANDATORY: + if ( + allauth_settings.EMAIL_VERIFICATION + != allauth_settings.EmailVerificationMethod.MANDATORY + ): if dj_rest_auth_settings.USE_JWT: self.access_token, self.refresh_token = jwt_encode(user) elif self.token_model: dj_rest_auth_settings.TOKEN_CREATOR(self.token_model, user, serializer) complete_signup( - self.request._request, user, + self.request._request, + user, allauth_settings.EMAIL_VERIFICATION, None, ) return user + def _etag(etag_func): """ Decorator to support conditional retrieval (or change) @@ -141,6 +151,7 @@ def _etag(etag_func): It calls Django's original decorator but pass correct request object to it. Django's original decorator doesn't work with DRF request object. """ + def decorator(func): @functools.wraps(func) def wrapper(obj_self, request, *args, **kwargs): @@ -153,9 +164,12 @@ def patched_viewset_method(*_args, **_kwargs): return func(obj_self, drf_request, *args, **kwargs) return patched_viewset_method(wsgi_request, *args, **kwargs) + return wrapper + return decorator + class RulesView(views.APIView): serializer_class = None permission_classes = [AllowAny] @@ -164,10 +178,11 @@ class RulesView(views.APIView): @_etag(lambda request: get_opa_bundle()[1]) def get(self, request): - return HttpResponse(get_opa_bundle()[0], content_type='application/x-tar') + return HttpResponse(get_opa_bundle()[0], content_type="application/x-tar") + class ConfirmEmailViewEx(ConfirmEmailView): - template_name = 'account/email/email_confirmation_signup_message.html' + template_name = "account/email/email_confirmation_signup_message.html" def get(self, *args, **kwargs): try: diff --git a/cvat/apps/lambda_manager/apps.py b/cvat/apps/lambda_manager/apps.py index 1bbc515522ad..974e32dc74a4 100644 --- a/cvat/apps/lambda_manager/apps.py +++ b/cvat/apps/lambda_manager/apps.py @@ -7,8 +7,9 @@ class LambdaManagerConfig(AppConfig): - name = 'cvat.apps.lambda_manager' + name = "cvat.apps.lambda_manager" def ready(self) -> None: from cvat.apps.iam.permissions import load_app_permissions + load_app_permissions(self) diff --git a/cvat/apps/lambda_manager/permissions.py b/cvat/apps/lambda_manager/permissions.py index 06a43253fa3c..a2192cdd4914 100644 --- a/cvat/apps/lambda_manager/permissions.py +++ b/cvat/apps/lambda_manager/permissions.py @@ -11,25 +11,25 @@ class LambdaPermission(OpenPolicyAgentPermission): class Scopes(StrEnum): - LIST = 'list' - VIEW = 'view' - CALL_ONLINE = 'call:online' - CALL_OFFLINE = 'call:offline' - LIST_OFFLINE = 'list:offline' + LIST = "list" + VIEW = "view" + CALL_ONLINE = "call:online" + CALL_OFFLINE = "call:offline" + LIST_OFFLINE = "list:offline" @classmethod def create(cls, request, view, obj, iam_context): permissions = [] - if view.basename == 'lambda_function' or view.basename == 'lambda_request': + if view.basename == "lambda_function" or view.basename == "lambda_request": scopes = cls.get_scopes(request, view, obj) for scope in scopes: self = cls.create_base_perm(request, view, scope, iam_context, obj) permissions.append(self) - if job_id := request.data.get('job'): + if job_id := request.data.get("job"): perm = JobPermission.create_scope_view_data(iam_context, job_id) permissions.append(perm) - elif task_id := request.data.get('task'): + elif task_id := request.data.get("task"): perm = TaskPermission.create_scope_view_data(iam_context, task_id) permissions.append(perm) @@ -37,20 +37,22 @@ def create(cls, request, view, obj, iam_context): def __init__(self, **kwargs): super().__init__(**kwargs) - self.url = settings.IAM_OPA_DATA_URL + '/lambda/allow' + self.url = settings.IAM_OPA_DATA_URL + "/lambda/allow" @staticmethod def get_scopes(request, view, obj): Scopes = __class__.Scopes - return [{ - ('lambda_function', 'list'): Scopes.LIST, - ('lambda_function', 'retrieve'): Scopes.VIEW, - ('lambda_function', 'call'): Scopes.CALL_ONLINE, - ('lambda_request', 'create'): Scopes.CALL_OFFLINE, - ('lambda_request', 'list'): Scopes.LIST_OFFLINE, - ('lambda_request', 'retrieve'): Scopes.CALL_OFFLINE, - ('lambda_request', 'destroy'): Scopes.CALL_OFFLINE, - }[(view.basename, view.action)]] + return [ + { + ("lambda_function", "list"): Scopes.LIST, + ("lambda_function", "retrieve"): Scopes.VIEW, + ("lambda_function", "call"): Scopes.CALL_ONLINE, + ("lambda_request", "create"): Scopes.CALL_OFFLINE, + ("lambda_request", "list"): Scopes.LIST_OFFLINE, + ("lambda_request", "retrieve"): Scopes.CALL_OFFLINE, + ("lambda_request", "destroy"): Scopes.CALL_OFFLINE, + }[(view.basename, view.action)] + ] def get_resource(self): return None diff --git a/cvat/apps/lambda_manager/rules/tests/generators/lambda_test.gen.rego.py b/cvat/apps/lambda_manager/rules/tests/generators/lambda_test.gen.rego.py index 94f694988a38..f506fda56a07 100644 --- a/cvat/apps/lambda_manager/rules/tests/generators/lambda_test.gen.rego.py +++ b/cvat/apps/lambda_manager/rules/tests/generators/lambda_test.gen.rego.py @@ -77,13 +77,15 @@ def get_data(scope, context, ownership, privilege, membership, resource): "scope": scope, "auth": { "user": {"id": random.randrange(0, 100), "privilege": privilege}, - "organization": { - "id": random.randrange(100, 200), - "owner": {"id": random.randrange(200, 300)}, - "user": {"role": membership}, - } - if context == "organization" - else None, + "organization": ( + { + "id": random.randrange(100, 200), + "owner": {"id": random.randrange(200, 300)}, + "user": {"role": membership}, + } + if context == "organization" + else None + ), }, "resource": resource, } diff --git a/cvat/apps/lambda_manager/serializers.py b/cvat/apps/lambda_manager/serializers.py index 96ffffa5702c..8daf3a53642b 100644 --- a/cvat/apps/lambda_manager/serializers.py +++ b/cvat/apps/lambda_manager/serializers.py @@ -10,16 +10,20 @@ class SublabelMappingEntrySerializer(serializers.Serializer): name = serializers.CharField() attributes = serializers.DictField(child=serializers.CharField(), required=False) + class LabelMappingEntrySerializer(serializers.Serializer): name = serializers.CharField() attributes = serializers.DictField(child=serializers.CharField(), required=False) - sublabels = serializers.DictField(child=SublabelMappingEntrySerializer(), required=False, - help_text="Label mapping for from the model to the task sublabels within a parent label" + sublabels = serializers.DictField( + child=SublabelMappingEntrySerializer(), + required=False, + help_text="Label mapping for from the model to the task sublabels within a parent label", ) + @extend_schema_serializer( # The "Request" suffix is added by drf-spectacular automatically - component_name='FunctionCall' + component_name="FunctionCall" ) class FunctionCallRequestSerializer(serializers.Serializer): function = serializers.CharField(help_text="The name of the function to execute") @@ -27,12 +31,24 @@ class FunctionCallRequestSerializer(serializers.Serializer): job = serializers.IntegerField(required=False, help_text="The id of the job to be annotated") max_distance = serializers.IntegerField(required=False) threshold = serializers.FloatField(required=False) - cleanup = serializers.BooleanField(help_text="Whether existing annotations should be removed", default=False) - convMaskToPoly = serializers.BooleanField(required=False, source="conv_mask_to_poly", write_only=True, help_text="Deprecated; use conv_mask_to_poly instead") - conv_mask_to_poly = serializers.BooleanField(required=False, help_text="Convert mask shapes to polygons") - mapping = serializers.DictField(child=LabelMappingEntrySerializer(), required=False, - help_text="Label mapping from the model to the task labels" + cleanup = serializers.BooleanField( + help_text="Whether existing annotations should be removed", default=False + ) + convMaskToPoly = serializers.BooleanField( + required=False, + source="conv_mask_to_poly", + write_only=True, + help_text="Deprecated; use conv_mask_to_poly instead", + ) + conv_mask_to_poly = serializers.BooleanField( + required=False, help_text="Convert mask shapes to polygons" ) + mapping = serializers.DictField( + child=LabelMappingEntrySerializer(), + required=False, + help_text="Label mapping from the model to the task labels", + ) + class FunctionCallParamsSerializer(serializers.Serializer): id = serializers.CharField(allow_null=True, help_text="The name of the function") @@ -42,6 +58,7 @@ class FunctionCallParamsSerializer(serializers.Serializer): threshold = serializers.FloatField(allow_null=True) + class FunctionCallSerializer(serializers.Serializer): id = serializers.CharField(help_text="Request id") diff --git a/cvat/apps/lambda_manager/tests/test_lambda.py b/cvat/apps/lambda_manager/tests/test_lambda.py index 3e5cc43f8743..38e812b25ff0 100644 --- a/cvat/apps/lambda_manager/tests/test_lambda.py +++ b/cvat/apps/lambda_manager/tests/test_lambda.py @@ -23,13 +23,15 @@ get_paginated_collection, ) -LAMBDA_ROOT_PATH = '/api/lambda' -LAMBDA_FUNCTIONS_PATH = f'{LAMBDA_ROOT_PATH}/functions' -LAMBDA_REQUESTS_PATH = f'{LAMBDA_ROOT_PATH}/requests' +LAMBDA_ROOT_PATH = "/api/lambda" +LAMBDA_FUNCTIONS_PATH = f"{LAMBDA_ROOT_PATH}/functions" +LAMBDA_REQUESTS_PATH = f"{LAMBDA_ROOT_PATH}/requests" id_function_detector = "test-openvino-omz-public-yolo-v3-tf" id_function_reid_with_response_data = "test-openvino-omz-intel-person-reidentification-retail-0300" -id_function_reid_with_no_response_data = "test-openvino-omz-intel-person-reidentification-retail-1234" +id_function_reid_with_no_response_data = ( + "test-openvino-omz-intel-person-reidentification-retail-1234" +) id_function_interactor = "test-openvino-dextr" id_function_tracker = "test-pth-foolwood-siammask" id_function_non_type = "test-model-has-non-type" @@ -40,29 +42,47 @@ id_function_state_error = "test-model-has-state-error" expected_keys_in_response_all_functions = ["id", "kind", "labels_v2", "description", "name"] -expected_keys_in_response_function_interactor = expected_keys_in_response_all_functions + ["min_pos_points", "startswith_box"] -expected_keys_in_response_requests = ["id", "function", "status", "progress", "enqueued", "started", "ended", "exc_info"] - -path = os.path.join(os.path.dirname(__file__), 'assets', 'tasks.json') +expected_keys_in_response_function_interactor = expected_keys_in_response_all_functions + [ + "min_pos_points", + "startswith_box", +] +expected_keys_in_response_requests = [ + "id", + "function", + "status", + "progress", + "enqueued", + "started", + "ended", + "exc_info", +] + +path = os.path.join(os.path.dirname(__file__), "assets", "tasks.json") with open(path) as f: tasks = json.load(f) # removed unnecessary data -path = os.path.join(os.path.dirname(__file__), 'assets', 'functions.json') +path = os.path.join(os.path.dirname(__file__), "assets", "functions.json") with open(path) as f: functions = json.load(f) + class _LambdaTestCaseBase(ApiTestBase): def setUp(self): super().setUp() self.client = self.client_class(raise_request_exception=False) - http_patcher = mock.patch('cvat.apps.lambda_manager.views.LambdaGateway._http', side_effect = self._get_data_from_lambda_manager_http) + http_patcher = mock.patch( + "cvat.apps.lambda_manager.views.LambdaGateway._http", + side_effect=self._get_data_from_lambda_manager_http, + ) self.addCleanup(http_patcher.stop) http_patcher.start() - invoke_patcher = mock.patch('cvat.apps.lambda_manager.views.LambdaGateway.invoke', side_effect = self._invoke_function) + invoke_patcher = mock.patch( + "cvat.apps.lambda_manager.views.LambdaGateway.invoke", side_effect=self._invoke_function + ) self.addCleanup(invoke_patcher.stop) invoke_patcher.start() @@ -76,13 +96,13 @@ def _get_data_from_lambda_manager_http(self, **kwargs): if func_id in [id_function_state_building, id_function_state_error]: r = requests.RequestException() r.response = HttpResponseServerError() - raise r # raise 500 Internal_Server error + raise r # raise 500 Internal_Server error return functions["positive"][func_id] else: r = requests.HTTPError() r.response = HttpResponseNotFound() - raise r # raise 404 Not Found error + raise r # raise 404 Not Found error def _invoke_function(self, func, payload): data = [] @@ -139,27 +159,32 @@ def _create_db_users(cls): (group_admin, _) = Group.objects.get_or_create(name="admin") (group_user, _) = Group.objects.get_or_create(name="user") - user_admin = User.objects.create_superuser(username="admin", email="", - password="admin") + user_admin = User.objects.create_superuser(username="admin", email="", password="admin") user_admin.groups.add(group_admin) - user_dummy = User.objects.create_user(username="user", password="user", - email="user@example.com") + user_dummy = User.objects.create_user( + username="user", password="user", email="user@example.com" + ) user_dummy.groups.add(group_user) cls.admin = user_admin cls.user = user_dummy - def _create_task(self, task_spec, data, *, owner=None, org_id=None): with ForceLogin(owner or self.admin, self.client): - response = self.client.post('/api/tasks', data=task_spec, format="json", - QUERY_STRING=f'org_id={org_id}' if org_id is not None else None) + response = self.client.post( + "/api/tasks", + data=task_spec, + format="json", + QUERY_STRING=f"org_id={org_id}" if org_id is not None else None, + ) assert response.status_code == status.HTTP_201_CREATED, response.status_code tid = response.data["id"] - response = self.client.post("/api/tasks/%s/data" % tid, + response = self.client.post( + "/api/tasks/%s/data" % tid, data=data, - QUERY_STRING=f'org_id={org_id}' if org_id is not None else None) + QUERY_STRING=f"org_id={org_id}" if org_id is not None else None, + ) assert response.status_code == status.HTTP_202_ACCEPTED, response.status_code rq_id = response.json()["rq_id"] @@ -167,65 +192,72 @@ def _create_task(self, task_spec, data, *, owner=None, org_id=None): assert response.status_code == status.HTTP_200_OK, response.status_code assert response.json()["status"] == "finished", response.json().get("status") - response = self.client.get("/api/tasks/%s" % tid, - QUERY_STRING=f'org_id={org_id}' if org_id is not None else None) + response = self.client.get( + "/api/tasks/%s" % tid, + QUERY_STRING=f"org_id={org_id}" if org_id is not None else None, + ) task = response.data return task - - def _generate_task_images(self, count): # pylint: disable=no-self-use + def _generate_task_images(self, count): # pylint: disable=no-self-use images = { - "client_files[%d]" % i: generate_image_file("image_%d.jpg" % i) - for i in range(count) + "client_files[%d]" % i: generate_image_file("image_%d.jpg" % i) for i in range(count) } images["image_quality"] = 75 return images - @classmethod def setUpTestData(cls): cls._create_db_users() - def _get_request(self, path, user, *, org_id=None): with ForceLogin(user, self.client): - response = self.client.get(path, - QUERY_STRING=f'org_id={org_id}' if org_id is not None else '') + response = self.client.get( + path, QUERY_STRING=f"org_id={org_id}" if org_id is not None else "" + ) return response - def _delete_request(self, path, user, *, org_id=None): with ForceLogin(user, self.client): - response = self.client.delete(path, - QUERY_STRING=f'org_id={org_id}' if org_id is not None else '') + response = self.client.delete( + path, QUERY_STRING=f"org_id={org_id}" if org_id is not None else "" + ) return response - def _post_request(self, path, user, data, *, org_id=None): data = json.dumps(data) with ForceLogin(user, self.client): - response = self.client.post(path, data=data, content_type='application/json', - QUERY_STRING=f'org_id={org_id}' if org_id is not None else '') + response = self.client.post( + path, + data=data, + content_type="application/json", + QUERY_STRING=f"org_id={org_id}" if org_id is not None else "", + ) return response - def _patch_request(self, path, user, data, *, org_id=None): data = json.dumps(data) with ForceLogin(user, self.client): - response = self.client.patch(path, data=data, content_type='application/json', - QUERY_STRING=f'org_id={org_id}' if org_id is not None else '') + response = self.client.patch( + path, + data=data, + content_type="application/json", + QUERY_STRING=f"org_id={org_id}" if org_id is not None else "", + ) return response - def _put_request(self, path, user, data, *, org_id=None): data = json.dumps(data) with ForceLogin(user, self.client): - response = self.client.put(path, data=data, content_type='application/json', - QUERY_STRING=f'org_id={org_id}' if org_id is not None else '') + response = self.client.put( + path, + data=data, + content_type="application/json", + QUERY_STRING=f"org_id={org_id}" if org_id is not None else "", + ) return response - def _check_expected_keys_in_response_function(self, data): kind = data["kind"] if kind == "interactor": @@ -236,7 +268,7 @@ def _check_expected_keys_in_response_function(self, data): self.assertIn(key, data) def _delete_lambda_request(self, request_id: str, user: Optional[User] = None) -> None: - response = self._delete_request(f'{LAMBDA_REQUESTS_PATH}/{request_id}', user or self.admin) + response = self._delete_request(f"{LAMBDA_REQUESTS_PATH}/{request_id}", user or self.admin) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) @@ -265,8 +297,7 @@ def test_api_v2_lambda_functions_list(self): response = self._get_request(LAMBDA_FUNCTIONS_PATH, None) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - - @mock.patch('cvat.apps.lambda_manager.views.LambdaGateway._http', return_value = {}) + @mock.patch("cvat.apps.lambda_manager.views.LambdaGateway._http", return_value={}) def test_api_v2_lambda_functions_list_empty(self, mock_http): response = self._get_request(LAMBDA_FUNCTIONS_PATH, self.admin) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -279,10 +310,12 @@ def test_api_v2_lambda_functions_list_empty(self, mock_http): response = self._get_request(LAMBDA_FUNCTIONS_PATH, None) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - @mock.patch( - 'cvat.apps.lambda_manager.views.LambdaGateway._http', - return_value={**functions["negative"], id_function_detector: functions["positive"][id_function_detector]} + "cvat.apps.lambda_manager.views.LambdaGateway._http", + return_value={ + **functions["negative"], + id_function_detector: functions["positive"][id_function_detector], + }, ) def test_api_v2_lambda_functions_list_negative(self, mock_http): response = self._get_request(LAMBDA_FUNCTIONS_PATH, self.admin) @@ -293,11 +326,15 @@ def test_api_v2_lambda_functions_list_negative(self, mock_http): self.assertEqual(visible_ids, {id_function_detector}) def test_api_v2_lambda_functions_read(self): - ids_functions = [id_function_detector, id_function_interactor,\ - id_function_tracker, id_function_reid_with_response_data] + ids_functions = [ + id_function_detector, + id_function_interactor, + id_function_tracker, + id_function_reid_with_response_data, + ] for id_func in ids_functions: - path = f'{LAMBDA_FUNCTIONS_PATH}/{id_func}' + path = f"{LAMBDA_FUNCTIONS_PATH}/{id_func}" response = self._get_request(path, self.admin) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -310,32 +347,31 @@ def test_api_v2_lambda_functions_read(self): response = self._get_request(path, None) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - def test_api_v2_lambda_functions_read_wrong_id(self): id_wrong_function = "test-functions-wrong-id" - response = self._get_request(f'{LAMBDA_FUNCTIONS_PATH}/{id_wrong_function}', self.admin) + response = self._get_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_wrong_function}", self.admin) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - response = self._get_request(f'{LAMBDA_FUNCTIONS_PATH}/{id_wrong_function}', self.user) + response = self._get_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_wrong_function}", self.user) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - response = self._get_request(f'{LAMBDA_FUNCTIONS_PATH}/{id_wrong_function}', None) + response = self._get_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_wrong_function}", None) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - def test_api_v2_lambda_functions_read_negative(self): for id_func in [ - id_function_non_type, id_function_wrong_type, id_function_unknown_type, + id_function_non_type, + id_function_wrong_type, + id_function_unknown_type, id_function_non_unique_labels, ]: with mock.patch( - 'cvat.apps.lambda_manager.views.LambdaGateway._http', - return_value=functions["negative"][id_func] + "cvat.apps.lambda_manager.views.LambdaGateway._http", + return_value=functions["negative"][id_func], ): - response = self._get_request(f'{LAMBDA_FUNCTIONS_PATH}/{id_func}', self.admin) + response = self._get_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_func}", self.admin) self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) - @skip("Fail: add mock") def test_api_v2_lambda_requests_list(self): response = self._get_request(LAMBDA_REQUESTS_PATH, self.admin) @@ -351,7 +387,6 @@ def test_api_v2_lambda_requests_list(self): response = self._get_request(LAMBDA_REQUESTS_PATH, None) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - def test_api_v2_lambda_requests_list_empty(self): response = self._get_request(LAMBDA_REQUESTS_PATH, self.admin) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -364,7 +399,6 @@ def test_api_v2_lambda_requests_list_empty(self): response = self._get_request(LAMBDA_REQUESTS_PATH, None) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - def test_api_v2_lambda_requests_read(self): # create request data_main_task = { @@ -373,76 +407,78 @@ def test_api_v2_lambda_requests_read(self): "cleanup": True, "threshold": 55, "mapping": { - "car": { "name": "car" }, + "car": {"name": "car"}, }, } response = self._post_request(LAMBDA_REQUESTS_PATH, self.admin, data_main_task) self.assertEqual(response.status_code, status.HTTP_200_OK) id_request = response.data["id"] - response = self._get_request(f'{LAMBDA_REQUESTS_PATH}/{id_request}', self.admin) + response = self._get_request(f"{LAMBDA_REQUESTS_PATH}/{id_request}", self.admin) self.assertEqual(response.status_code, status.HTTP_200_OK) for key in expected_keys_in_response_requests: self.assertIn(key, response.data) - response = self._get_request(f'{LAMBDA_REQUESTS_PATH}/{id_request}', self.user) + response = self._get_request(f"{LAMBDA_REQUESTS_PATH}/{id_request}", self.user) self.assertEqual(response.status_code, status.HTTP_200_OK) for key in expected_keys_in_response_requests: self.assertIn(key, response.data) - response = self._get_request(f'{LAMBDA_REQUESTS_PATH}/{id_request}', None) + response = self._get_request(f"{LAMBDA_REQUESTS_PATH}/{id_request}", None) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - def test_api_v2_lambda_requests_read_wrong_id(self): id_request = "cf343b95-afeb-475e-ab53-8d7e64991d30-wrong-id" - response = self._get_request(f'{LAMBDA_REQUESTS_PATH}/{id_request}', self.admin) + response = self._get_request(f"{LAMBDA_REQUESTS_PATH}/{id_request}", self.admin) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - response = self._get_request(f'{LAMBDA_REQUESTS_PATH}/{id_request}', self.user) + response = self._get_request(f"{LAMBDA_REQUESTS_PATH}/{id_request}", self.user) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - response = self._get_request(f'{LAMBDA_REQUESTS_PATH}/{id_request}', None) + response = self._get_request(f"{LAMBDA_REQUESTS_PATH}/{id_request}", None) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - def test_api_v2_lambda_requests_delete_finished_request(self): data = { "function": id_function_detector, "task": self.main_task["id"], "cleanup": True, "mapping": { - "car": { "name": "car" }, + "car": {"name": "car"}, }, } response = self._post_request(LAMBDA_REQUESTS_PATH, self.admin, data) id_request = response.data["id"] - response = self._delete_request(f'{LAMBDA_REQUESTS_PATH}/{id_request}', None) + response = self._delete_request(f"{LAMBDA_REQUESTS_PATH}/{id_request}", None) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - response = self._delete_request(f'{LAMBDA_REQUESTS_PATH}/{id_request}', self.admin) + response = self._delete_request(f"{LAMBDA_REQUESTS_PATH}/{id_request}", self.admin) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - response = self._get_request(f'{LAMBDA_REQUESTS_PATH}/{id_request}', self.admin) + response = self._get_request(f"{LAMBDA_REQUESTS_PATH}/{id_request}", self.admin) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) response = self._post_request(LAMBDA_REQUESTS_PATH, self.admin, data) id_request = response.data["id"] - response = self._delete_request(f'{LAMBDA_REQUESTS_PATH}/{id_request}', self.user) + response = self._delete_request(f"{LAMBDA_REQUESTS_PATH}/{id_request}", self.user) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - response = self._get_request(f'{LAMBDA_REQUESTS_PATH}/{id_request}', self.user) + response = self._get_request(f"{LAMBDA_REQUESTS_PATH}/{id_request}", self.user) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - @skip("Fail: add mock") def test_api_v2_lambda_requests_delete_not_finished_request(self): pass - def test_api_v2_lambda_requests_create(self): - ids_functions = [id_function_detector, id_function_interactor, id_function_tracker, \ - id_function_reid_with_response_data, id_function_detector, id_function_reid_with_no_response_data] + ids_functions = [ + id_function_detector, + id_function_interactor, + id_function_tracker, + id_function_reid_with_response_data, + id_function_detector, + id_function_reid_with_no_response_data, + ] for id_func in ids_functions: data_main_task = { @@ -451,7 +487,7 @@ def test_api_v2_lambda_requests_create(self): "cleanup": True, "threshold": 55, "mapping": { - "car": { "name": "car" }, + "car": {"name": "car"}, }, } data_assigneed_to_user_task = { @@ -460,7 +496,7 @@ def test_api_v2_lambda_requests_create(self): "cleanup": False, "max_distance": 70, "mapping": { - "car": { "name": "car" }, + "car": {"name": "car"}, }, } @@ -471,7 +507,9 @@ def test_api_v2_lambda_requests_create(self): self._delete_lambda_request(response.data["id"]) - response = self._post_request(LAMBDA_REQUESTS_PATH, self.user, data_assigneed_to_user_task) + response = self._post_request( + LAMBDA_REQUESTS_PATH, self.user, data_assigneed_to_user_task + ) self.assertEqual(response.status_code, status.HTTP_200_OK) for key in expected_keys_in_response_requests: self.assertIn(key, response.data) @@ -484,10 +522,11 @@ def test_api_v2_lambda_requests_create(self): response = self._post_request(LAMBDA_REQUESTS_PATH, None, data_main_task) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - def test_api_v2_lambda_requests_create_negative(self): for id_func in [ - id_function_non_type, id_function_wrong_type, id_function_unknown_type, + id_function_non_type, + id_function_wrong_type, + id_function_unknown_type, id_function_non_unique_labels, ]: data = { @@ -495,49 +534,45 @@ def test_api_v2_lambda_requests_create_negative(self): "task": self.main_task["id"], "cleanup": True, "mapping": { - "car": { "name": "car" }, + "car": {"name": "car"}, }, } with mock.patch( - 'cvat.apps.lambda_manager.views.LambdaGateway._http', + "cvat.apps.lambda_manager.views.LambdaGateway._http", return_value=functions["negative"][id_func], ): response = self._post_request(LAMBDA_REQUESTS_PATH, self.admin, data) self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) - def test_api_v2_lambda_requests_create_empty_data(self): data = {} response = self._post_request(LAMBDA_REQUESTS_PATH, self.admin, data) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - def test_api_v2_lambda_requests_create_without_function(self): data = { "task": self.main_task["id"], "cleanup": True, "mapping": { - "car": { "name": "car" }, + "car": {"name": "car"}, }, } response = self._post_request(LAMBDA_REQUESTS_PATH, self.admin, data) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - def test_api_v2_lambda_requests_create_wrong_id_function(self): data = { "function": "test-requests-wrong-id", "task": self.main_task["id"], "cleanup": True, "mapping": { - "car": { "name": "car" }, + "car": {"name": "car"}, }, } response = self._post_request(LAMBDA_REQUESTS_PATH, self.admin, data) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - @skip("Fail: add mock") def test_api_v2_lambda_requests_create_two_requests(self): data = { @@ -545,10 +580,10 @@ def test_api_v2_lambda_requests_create_two_requests(self): "task": self.main_task["id"], "cleanup": True, "mapping": { - "car": { "name": "car" }, + "car": {"name": "car"}, }, } - request_id = self._post_request(LAMBDA_REQUESTS_PATH, self.admin, data).data['id'] + request_id = self._post_request(LAMBDA_REQUESTS_PATH, self.admin, data).data["id"] response = self._post_request(LAMBDA_REQUESTS_PATH, self.admin, data) self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) @@ -573,7 +608,7 @@ def test_api_v2_lambda_requests_create_without_cleanup(self): "function": id_function_detector, "task": self.main_task["id"], "mapping": { - "car": { "name": "car" }, + "car": {"name": "car"}, }, } response = self._post_request(LAMBDA_REQUESTS_PATH, self.admin, data) @@ -601,26 +636,24 @@ def test_api_v2_lambda_requests_create_without_task(self): "function": id_function_detector, "cleanup": True, "mapping": { - "car": { "name": "car" }, + "car": {"name": "car"}, }, } response = self._post_request(LAMBDA_REQUESTS_PATH, self.admin, data) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - def test_api_v2_lambda_requests_create_wrong_id_task(self): data = { "function": id_function_detector, "task": 12345, "cleanup": True, "mapping": { - "car": { "name": "car" }, + "car": {"name": "car"}, }, } response = self._post_request(LAMBDA_REQUESTS_PATH, self.admin, data) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - def test_api_v2_lambda_requests_create_is_not_ready(self): ids_functions = [id_function_state_building, id_function_state_error] @@ -630,14 +663,13 @@ def test_api_v2_lambda_requests_create_is_not_ready(self): "task": self.main_task["id"], "cleanup": True, "mapping": { - "car": { "name": "car" }, + "car": {"name": "car"}, }, } response = self._post_request(LAMBDA_REQUESTS_PATH, self.admin, data) self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) - def test_api_v2_lambda_functions_create_detector(self): data_main_task = { "task": self.main_task["id"], @@ -645,7 +677,7 @@ def test_api_v2_lambda_functions_create_detector(self): "cleanup": True, "threshold": 0.55, "mapping": { - "car": { "name": "car" }, + "car": {"name": "car"}, }, } data_assigneed_to_user_task = { @@ -653,122 +685,199 @@ def test_api_v2_lambda_functions_create_detector(self): "frame": 0, "cleanup": True, "mapping": { - "car": { "name": "car" }, + "car": {"name": "car"}, }, } - response = self._post_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_function_detector}", self.admin, data_main_task) + response = self._post_request( + f"{LAMBDA_FUNCTIONS_PATH}/{id_function_detector}", self.admin, data_main_task + ) self.assertEqual(response.status_code, status.HTTP_200_OK) - response = self._post_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_function_detector}", self.user, data_assigneed_to_user_task) + response = self._post_request( + f"{LAMBDA_FUNCTIONS_PATH}/{id_function_detector}", + self.user, + data_assigneed_to_user_task, + ) self.assertEqual(response.status_code, status.HTTP_200_OK) - response = self._post_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_function_detector}", None, data_main_task) + response = self._post_request( + f"{LAMBDA_FUNCTIONS_PATH}/{id_function_detector}", None, data_main_task + ) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - @skip("Fail: expected result != actual result") # TODO move test to test_api_v2_lambda_functions_create + @skip( + "Fail: expected result != actual result" + ) # TODO move test to test_api_v2_lambda_functions_create def test_api_v2_lambda_functions_create_user_assigned_to_no_user(self): data = { "task": self.main_task["id"], "frame": 0, "cleanup": True, "mapping": { - "car": { "name": "car" }, + "car": {"name": "car"}, }, } - response = self._post_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_function_detector}", self.user, data) + response = self._post_request( + f"{LAMBDA_FUNCTIONS_PATH}/{id_function_detector}", self.user, data + ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_api_v2_lambda_functions_create_interactor(self): data_main_task = { "task": self.main_task["id"], "frame": 0, "pos_points": [ - [3.45, 6.78], - [12.1, 12.1], + [3.45, 6.78], + [12.1, 12.1], [34.1, 41.0], - [43.01, 43.99], + [43.01, 43.99], ], "neg_points": [ - [3.25, 6.58], - [11.1, 11.0], - [35.5, 44.44], - [45.01, 45.99], - ], + [3.25, 6.58], + [11.1, 11.0], + [35.5, 44.44], + [45.01, 45.99], + ], } data_assigneed_to_user_task = { "task": self.assigneed_to_user_task["id"], "frame": 0, "threshold": 0.1, "pos_points": [ - [3.45, 6.78], - [12.1, 12.1], + [3.45, 6.78], + [12.1, 12.1], [34.1, 41.0], - [43.01, 43.99], + [43.01, 43.99], ], "neg_points": [ - [3.25, 6.58], - [11.1, 11.0], - [35.5, 44.44], - [45.01, 45.99], - ], + [3.25, 6.58], + [11.1, 11.0], + [35.5, 44.44], + [45.01, 45.99], + ], } - response = self._post_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_function_interactor}", self.admin, data_main_task) + response = self._post_request( + f"{LAMBDA_FUNCTIONS_PATH}/{id_function_interactor}", self.admin, data_main_task + ) self.assertEqual(response.status_code, status.HTTP_200_OK) - response = self._post_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_function_interactor}", self.user, data_assigneed_to_user_task) + response = self._post_request( + f"{LAMBDA_FUNCTIONS_PATH}/{id_function_interactor}", + self.user, + data_assigneed_to_user_task, + ) self.assertEqual(response.status_code, status.HTTP_200_OK) - response = self._post_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_function_interactor}", None, data_main_task) + response = self._post_request( + f"{LAMBDA_FUNCTIONS_PATH}/{id_function_interactor}", None, data_main_task + ) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - def test_api_v2_lambda_functions_create_tracker(self): data_main_task = { "task": self.main_task["id"], "frame": 0, "shape": [ - 12.12, - 34.45, - 54.0, - 76.12, - ], + 12.12, + 34.45, + 54.0, + 76.12, + ], } data_assigneed_to_user_task = { "task": self.assigneed_to_user_task["id"], "frame": 0, "shape": [ - 12.12, - 34.45, - 54.0, - 76.12, - ], + 12.12, + 34.45, + 54.0, + 76.12, + ], } - response = self._post_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_function_tracker}", self.admin, data_main_task) + response = self._post_request( + f"{LAMBDA_FUNCTIONS_PATH}/{id_function_tracker}", self.admin, data_main_task + ) self.assertEqual(response.status_code, status.HTTP_200_OK) - response = self._post_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_function_tracker}", self.user, data_assigneed_to_user_task) + response = self._post_request( + f"{LAMBDA_FUNCTIONS_PATH}/{id_function_tracker}", self.user, data_assigneed_to_user_task + ) self.assertEqual(response.status_code, status.HTTP_200_OK) - response = self._post_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_function_tracker}", None, data_main_task) + response = self._post_request( + f"{LAMBDA_FUNCTIONS_PATH}/{id_function_tracker}", None, data_main_task + ) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - def test_api_v2_lambda_functions_create_reid(self): data_main_task = { "task": self.main_task["id"], "frame0": 0, "frame1": 1, "boxes0": [ - OrderedDict([('attributes', []), ('frame', 0), ('group', None), ('id', 11258), ('label_id', 8), ('occluded', False), ('path_id', 0), ('points', [137.0, 129.0, 457.0, 676.0]), ('source', 'auto'), ('type', 'rectangle'), ('z_order', 0)]), - OrderedDict([('attributes', []), ('frame', 0), ('group', None), ('id', 11259), ('label_id', 8), ('occluded', False), ('path_id', 1), ('points', [1511.0, 224.0, 1537.0, 437.0]), ('source', 'auto'), ('type', 'rectangle'), ('z_order', 0)]), + OrderedDict( + [ + ("attributes", []), + ("frame", 0), + ("group", None), + ("id", 11258), + ("label_id", 8), + ("occluded", False), + ("path_id", 0), + ("points", [137.0, 129.0, 457.0, 676.0]), + ("source", "auto"), + ("type", "rectangle"), + ("z_order", 0), + ] + ), + OrderedDict( + [ + ("attributes", []), + ("frame", 0), + ("group", None), + ("id", 11259), + ("label_id", 8), + ("occluded", False), + ("path_id", 1), + ("points", [1511.0, 224.0, 1537.0, 437.0]), + ("source", "auto"), + ("type", "rectangle"), + ("z_order", 0), + ] + ), ], "boxes1": [ - OrderedDict([('attributes', []), ('frame', 1), ('group', None), ('id', 11260), ('label_id', 8), ('occluded', False), ('points', [1076.0, 199.0, 1218.0, 593.0]), ('source', 'auto'), ('type', 'rectangle'), ('z_order', 0)]), - OrderedDict([('attributes', []), ('frame', 1), ('group', None), ('id', 11261), ('label_id', 8), ('occluded', False), ('points', [924.0, 177.0, 1090.0, 615.0]), ('source', 'auto'), ('type', 'rectangle'), ('z_order', 0)]), + OrderedDict( + [ + ("attributes", []), + ("frame", 1), + ("group", None), + ("id", 11260), + ("label_id", 8), + ("occluded", False), + ("points", [1076.0, 199.0, 1218.0, 593.0]), + ("source", "auto"), + ("type", "rectangle"), + ("z_order", 0), + ] + ), + OrderedDict( + [ + ("attributes", []), + ("frame", 1), + ("group", None), + ("id", 11261), + ("label_id", 8), + ("occluded", False), + ("points", [924.0, 177.0, 1090.0, 615.0]), + ("source", "auto"), + ("type", "rectangle"), + ("z_order", 0), + ] + ), ], "threshold": 0.5, "max_distance": 55, @@ -778,63 +887,154 @@ def test_api_v2_lambda_functions_create_reid(self): "frame0": 0, "frame1": 1, "boxes0": [ - OrderedDict([('attributes', []), ('frame', 0), ('group', None), ('id', 11258), ('label_id', 8), ('occluded', False), ('path_id', 0), ('points', [137.0, 129.0, 457.0, 676.0]), ('source', 'auto'), ('type', 'rectangle'), ('z_order', 0)]), - OrderedDict([('attributes', []), ('frame', 0), ('group', None), ('id', 11259), ('label_id', 8), ('occluded', False), ('path_id', 1), ('points', [1511.0, 224.0, 1537.0, 437.0]), ('source', 'auto'), ('type', 'rectangle'), ('z_order', 0)]), + OrderedDict( + [ + ("attributes", []), + ("frame", 0), + ("group", None), + ("id", 11258), + ("label_id", 8), + ("occluded", False), + ("path_id", 0), + ("points", [137.0, 129.0, 457.0, 676.0]), + ("source", "auto"), + ("type", "rectangle"), + ("z_order", 0), + ] + ), + OrderedDict( + [ + ("attributes", []), + ("frame", 0), + ("group", None), + ("id", 11259), + ("label_id", 8), + ("occluded", False), + ("path_id", 1), + ("points", [1511.0, 224.0, 1537.0, 437.0]), + ("source", "auto"), + ("type", "rectangle"), + ("z_order", 0), + ] + ), ], "boxes1": [ - OrderedDict([('attributes', []), ('frame', 1), ('group', None), ('id', 11260), ('label_id', 8), ('occluded', False), ('points', [1076.0, 199.0, 1218.0, 593.0]), ('source', 'auto'), ('type', 'rectangle'), ('z_order', 0)]), - OrderedDict([('attributes', []), ('frame', 1), ('group', 0), ('id', 11398), ('label_id', 8), ('occluded', False), ('points', [184.3935546875, 211.5048828125, 331.64968722073354, 97.27792672028772, 445.87667560321825, 126.17873100983161, 454.13404825737416, 691.8087578194827, 180.26452189455085]), ('source', 'manual'), ('type', 'polygon'), ('z_order', 0)]), + OrderedDict( + [ + ("attributes", []), + ("frame", 1), + ("group", None), + ("id", 11260), + ("label_id", 8), + ("occluded", False), + ("points", [1076.0, 199.0, 1218.0, 593.0]), + ("source", "auto"), + ("type", "rectangle"), + ("z_order", 0), + ] + ), + OrderedDict( + [ + ("attributes", []), + ("frame", 1), + ("group", 0), + ("id", 11398), + ("label_id", 8), + ("occluded", False), + ( + "points", + [ + 184.3935546875, + 211.5048828125, + 331.64968722073354, + 97.27792672028772, + 445.87667560321825, + 126.17873100983161, + 454.13404825737416, + 691.8087578194827, + 180.26452189455085, + ], + ), + ("source", "manual"), + ("type", "polygon"), + ("z_order", 0), + ] + ), ], } - response = self._post_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_function_reid_with_response_data}", self.admin, data_main_task) + response = self._post_request( + f"{LAMBDA_FUNCTIONS_PATH}/{id_function_reid_with_response_data}", + self.admin, + data_main_task, + ) self.assertEqual(response.status_code, status.HTTP_200_OK) - response = self._post_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_function_reid_with_response_data}", self.user, data_assigneed_to_user_task) + response = self._post_request( + f"{LAMBDA_FUNCTIONS_PATH}/{id_function_reid_with_response_data}", + self.user, + data_assigneed_to_user_task, + ) self.assertEqual(response.status_code, status.HTTP_200_OK) - response = self._post_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_function_reid_with_response_data}", None, data_main_task) + response = self._post_request( + f"{LAMBDA_FUNCTIONS_PATH}/{id_function_reid_with_response_data}", None, data_main_task + ) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - response = self._post_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_function_reid_with_no_response_data}", self.admin, data_main_task) + response = self._post_request( + f"{LAMBDA_FUNCTIONS_PATH}/{id_function_reid_with_no_response_data}", + self.admin, + data_main_task, + ) self.assertEqual(response.status_code, status.HTTP_200_OK) - response = self._post_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_function_reid_with_no_response_data}", self.user, data_assigneed_to_user_task) + response = self._post_request( + f"{LAMBDA_FUNCTIONS_PATH}/{id_function_reid_with_no_response_data}", + self.user, + data_assigneed_to_user_task, + ) self.assertEqual(response.status_code, status.HTTP_200_OK) - response = self._post_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_function_reid_with_no_response_data}", None, data_main_task) + response = self._post_request( + f"{LAMBDA_FUNCTIONS_PATH}/{id_function_reid_with_no_response_data}", + None, + data_main_task, + ) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - def test_api_v2_lambda_functions_create_negative(self): data = { "task": self.main_task["id"], "frame": 0, "cleanup": True, "mapping": { - "car": { "name": "car" }, + "car": {"name": "car"}, }, } for id_func in [ - id_function_non_type, id_function_wrong_type, id_function_unknown_type, + id_function_non_type, + id_function_wrong_type, + id_function_unknown_type, id_function_non_unique_labels, ]: with mock.patch( - 'cvat.apps.lambda_manager.views.LambdaGateway._http', - return_value=functions["negative"][id_func] + "cvat.apps.lambda_manager.views.LambdaGateway._http", + return_value=functions["negative"][id_func], ): - response = self._post_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_func}", self.admin, data) + response = self._post_request( + f"{LAMBDA_FUNCTIONS_PATH}/{id_func}", self.admin, data + ) self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) - def test_api_v2_lambda_functions_convert_mask_to_rle(self): data_main_task = { "function": id_function_detector, "task": self.main_task["id"], "cleanup": True, "mapping": { - "car": { "name": "car" }, + "car": {"name": "car"}, }, } response = self._post_request(LAMBDA_REQUESTS_PATH, self.admin, data_main_task) @@ -843,7 +1043,7 @@ def test_api_v2_lambda_functions_convert_mask_to_rle(self): request_status = "started" while request_status != "finished" and request_status != "failed": - response = self._get_request(f'{LAMBDA_REQUESTS_PATH}/{id_request}', self.admin) + response = self._get_request(f"{LAMBDA_REQUESTS_PATH}/{id_request}", self.admin) self.assertEqual(response.status_code, status.HTTP_200_OK) request_status = response.json().get("status") self.assertEqual(request_status, "finished") @@ -858,13 +1058,13 @@ def test_api_v2_lambda_functions_convert_mask_to_rle(self): # [1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0] -> [0, 2, 2, 2, 2, 2, 2] self.assertEqual(masks[0].get("points"), [0, 2, 2, 2, 2, 2, 2, 0, 0, 2, 3]) - def test_api_v2_lambda_functions_create_empty_data(self): data = {} - response = self._post_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_function_detector}", self.admin, data) + response = self._post_request( + f"{LAMBDA_FUNCTIONS_PATH}/{id_function_detector}", self.admin, data + ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - def test_api_v2_lambda_functions_create_detector_empty_mapping(self): data = { "task": self.main_task["id"], @@ -872,82 +1072,89 @@ def test_api_v2_lambda_functions_create_detector_empty_mapping(self): "cleanup": True, "mapping": {}, } - response = self._post_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_function_detector}", self.admin, data) + response = self._post_request( + f"{LAMBDA_FUNCTIONS_PATH}/{id_function_detector}", self.admin, data + ) self.assertEqual(response.status_code, status.HTTP_200_OK) - def test_api_v2_lambda_functions_create_detector_without_cleanup(self): data = { "task": self.main_task["id"], "frame": 0, "mapping": { - "car": { "name": "car" }, + "car": {"name": "car"}, }, } - response = self._post_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_function_detector}", self.admin, data) + response = self._post_request( + f"{LAMBDA_FUNCTIONS_PATH}/{id_function_detector}", self.admin, data + ) self.assertEqual(response.status_code, status.HTTP_200_OK) - def test_api_v2_lambda_functions_create_detector_without_mapping(self): data = { "task": self.main_task["id"], "frame": 0, "cleanup": True, } - response = self._post_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_function_detector}", self.admin, data) + response = self._post_request( + f"{LAMBDA_FUNCTIONS_PATH}/{id_function_detector}", self.admin, data + ) self.assertEqual(response.status_code, status.HTTP_200_OK) - def test_api_v2_lambda_functions_create_detector_without_task(self): data = { "frame": 0, "cleanup": True, "mapping": { - "car": { "name": "car" }, + "car": {"name": "car"}, }, } - response = self._post_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_function_detector}", self.admin, data) + response = self._post_request( + f"{LAMBDA_FUNCTIONS_PATH}/{id_function_detector}", self.admin, data + ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - def test_api_v2_lambda_functions_create_detector_without_id_frame(self): data = { "task": self.main_task["id"], "cleanup": True, "mapping": { - "car": { "name": "car" }, + "car": {"name": "car"}, }, } - response = self._post_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_function_detector}", self.admin, data) + response = self._post_request( + f"{LAMBDA_FUNCTIONS_PATH}/{id_function_detector}", self.admin, data + ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - def test_api_v2_lambda_functions_create_wrong_id_function(self): data = { "task": self.main_task["id"], "frame": 0, "cleanup": True, "mapping": { - "car": { "name": "car" }, + "car": {"name": "car"}, }, } - response = self._post_request(f"{LAMBDA_FUNCTIONS_PATH}/test-functions-wrong-id", self.admin, data) + response = self._post_request( + f"{LAMBDA_FUNCTIONS_PATH}/test-functions-wrong-id", self.admin, data + ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_api_v2_lambda_functions_create_wrong_id_task(self): data = { "task": 12345, "frame": 0, "cleanup": True, "mapping": { - "car": { "name": "car" }, + "car": {"name": "car"}, }, } - response = self._post_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_function_detector}", self.admin, data) + response = self._post_request( + f"{LAMBDA_FUNCTIONS_PATH}/{id_function_detector}", self.admin, data + ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - @skip("Fail: expected result != actual result, issue #2770") def test_api_v2_lambda_functions_create_detector_wrong_id_frame(self): data = { @@ -955,13 +1162,14 @@ def test_api_v2_lambda_functions_create_detector_wrong_id_frame(self): "frame": 12345, "cleanup": True, "mapping": { - "car": { "name": "car" }, + "car": {"name": "car"}, }, } - response = self._post_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_function_detector}", self.admin, data) + response = self._post_request( + f"{LAMBDA_FUNCTIONS_PATH}/{id_function_detector}", self.admin, data + ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - @skip("Fail: add mock and expected result != actual result") def test_api_v2_lambda_functions_create_two_functions(self): data = { @@ -969,27 +1177,32 @@ def test_api_v2_lambda_functions_create_two_functions(self): "frame": 0, "cleanup": True, "mapping": { - "car": { "name": "car" }, + "car": {"name": "car"}, }, } self._post_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_function_detector}", self.admin, data) - response = self._post_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_function_detector}", self.admin, data) + response = self._post_request( + f"{LAMBDA_FUNCTIONS_PATH}/{id_function_detector}", self.admin, data + ) self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) - def test_api_v2_lambda_functions_create_function_is_not_ready(self): data = { "task": self.main_task["id"], "frame": 0, "cleanup": True, "mapping": { - "car": { "name": "car" }, + "car": {"name": "car"}, }, } - response = self._post_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_function_state_building}", self.admin, data) + response = self._post_request( + f"{LAMBDA_FUNCTIONS_PATH}/{id_function_state_building}", self.admin, data + ) self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) - response = self._post_request(f"{LAMBDA_FUNCTIONS_PATH}/{id_function_state_error}", self.admin, data) + response = self._post_request( + f"{LAMBDA_FUNCTIONS_PATH}/{id_function_state_error}", self.admin, data + ) self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) @@ -1042,29 +1255,27 @@ def setUp(self): self.task = self._create_task( task_spec={ - 'name': 'test_task', - 'labels': [{'name': 'car'}], - 'segment_size': segment_size + "name": "test_task", + "labels": [{"name": "car"}], + "segment_size": segment_size, }, data=data, - owner=self.user + owner=self.user, ) self.task_rel_frame_range = range(len(range(start_frame, stop_frame, frame_step))) self.start_frame = start_frame self.frame_step = frame_step self.segment_size = segment_size - self.labels = get_paginated_collection(lambda page: - self._get_request( - f"/api/labels?task_id={self.task['id']}&page={page}&sort=id", - self.admin + self.labels = get_paginated_collection( + lambda page: self._get_request( + f"/api/labels?task_id={self.task['id']}&page={page}&sort=id", self.admin ) ) - self.jobs = get_paginated_collection(lambda page: - self._get_request( - f"/api/jobs?task_id={self.task['id']}&page={page}", - self.admin + self.jobs = get_paginated_collection( + lambda page: self._get_request( + f"/api/jobs?task_id={self.task['id']}&page={page}", self.admin ) ) @@ -1072,7 +1283,7 @@ def setUp(self): self.reid_function_id = id_function_reid_with_response_data self.common_request_data = { - "task": self.task['id'], + "task": self.task["id"], "cleanup": True, } @@ -1089,14 +1300,14 @@ def _run_offline_function(self, function_id, data, user): def _wait_request(self, request_id: str) -> str: request_status = "started" while request_status != "finished" and request_status != "failed": - response = self._get_request(f'{LAMBDA_REQUESTS_PATH}/{request_id}', self.admin) + response = self._get_request(f"{LAMBDA_REQUESTS_PATH}/{request_id}", self.admin) self.assertEqual(response.status_code, status.HTTP_200_OK) request_status = response.json().get("status") return request_status def _run_online_function(self, function_id, data, user): - response = self._post_request(f'{LAMBDA_FUNCTIONS_PATH}/{function_id}', user, data) + response = self._post_request(f"{LAMBDA_FUNCTIONS_PATH}/{function_id}", user, data) return response def test_can_run_offline_detector_function_on_whole_task(self): @@ -1112,40 +1323,39 @@ def test_can_run_offline_detector_function_on_whole_task(self): requested_frame_range = self.task_rel_frame_range self.assertEqual( - { - frame: 1 for frame in requested_frame_range - }, + {frame: 1 for frame in requested_frame_range}, { frame: len(list(group)) for frame, group in groupby(annotations["shapes"], key=lambda a: a["frame"]) - } + }, ) def test_can_run_offline_reid_function_on_whole_task(self): # Add starting shapes to be tracked on following frames requested_frame_range = self.task_rel_frame_range shape_template = { - 'attributes': [], - 'group': None, - 'label_id': self.labels[0]["id"], - 'occluded': False, - 'points': [0, 5, 5, 0], - 'source': 'manual', - 'type': 'rectangle', - 'z_order': 0, + "attributes": [], + "group": None, + "label_id": self.labels[0]["id"], + "occluded": False, + "points": [0, 5, 5, 0], + "source": "manual", + "type": "rectangle", + "z_order": 0, } - response = self._put_request(f'/api/tasks/{self.task["id"]}/annotations', self.admin, data={ - 'tags': [], - 'shapes': [ - { 'frame': frame, **shape_template } - for frame in requested_frame_range - ], - 'tracks': [] - }) + response = self._put_request( + f'/api/tasks/{self.task["id"]}/annotations', + self.admin, + data={ + "tags": [], + "shapes": [{"frame": frame, **shape_template} for frame in requested_frame_range], + "tracks": [], + }, + ) self.assertEqual(response.status_code, status.HTTP_200_OK) data = self.common_request_data.copy() - data["cleanup"] = False # cleanup is not compatible with reid + data["cleanup"] = False # cleanup is not compatible with reid self._run_offline_function(self.reid_function_id, data, self.user) response = self._get_request(f'/api/tasks/{self.task["id"]}/annotations', self.admin) @@ -1158,25 +1368,24 @@ def test_can_run_offline_reid_function_on_whole_task(self): [ # The single track will be split by job segments { - 'frame': job["start_frame"], - 'shapes': [ - { 'frame': frame, 'outside': frame > job["stop_frame"] } + "frame": job["start_frame"], + "shapes": [ + {"frame": frame, "outside": frame > job["stop_frame"]} for frame in requested_frame_range if frame in range(job["start_frame"], job["stop_frame"] + self.segment_size) - ] + ], } for job in sorted(self.jobs, key=lambda j: j["start_frame"]) ], [ { - 'frame': track['frame'], - 'shapes': [ - filter_dict(shape, keep=['frame', 'outside']) - for shape in track["shapes"] - ] + "frame": track["frame"], + "shapes": [ + filter_dict(shape, keep=["frame", "outside"]) for shape in track["shapes"] + ], } - for track in annotations['tracks'] - ] + for track in annotations["tracks"] + ], ) def test_can_run_offline_detector_function_on_whole_job(self): @@ -1194,13 +1403,11 @@ def test_can_run_offline_detector_function_on_whole_job(self): requested_frame_range = range(job["start_frame"], job["stop_frame"] + 1) self.assertEqual( - { - frame: 1 for frame in requested_frame_range - }, + {frame: 1 for frame in requested_frame_range}, { frame: len(list(group)) for frame, group in groupby(annotations["shapes"], key=lambda a: a["frame"]) - } + }, ) def test_can_run_offline_reid_function_on_whole_job(self): @@ -1209,27 +1416,28 @@ def test_can_run_offline_reid_function_on_whole_job(self): # Add starting shapes to be tracked on following frames shape_template = { - 'attributes': [], - 'group': None, - 'label_id': self.labels[0]["id"], - 'occluded': False, - 'points': [0, 5, 5, 0], - 'source': 'manual', - 'type': 'rectangle', - 'z_order': 0, + "attributes": [], + "group": None, + "label_id": self.labels[0]["id"], + "occluded": False, + "points": [0, 5, 5, 0], + "source": "manual", + "type": "rectangle", + "z_order": 0, } - response = self._put_request(f'/api/jobs/{job["id"]}/annotations', self.admin, data={ - 'tags': [], - 'shapes': [ - { 'frame': frame, **shape_template } - for frame in requested_frame_range - ], - 'tracks': [] - }) + response = self._put_request( + f'/api/jobs/{job["id"]}/annotations', + self.admin, + data={ + "tags": [], + "shapes": [{"frame": frame, **shape_template} for frame in requested_frame_range], + "tracks": [], + }, + ) self.assertEqual(response.status_code, status.HTTP_200_OK) data = self.common_request_data.copy() - data["cleanup"] = False # cleanup is not compatible with reid + data["cleanup"] = False # cleanup is not compatible with reid data["job"] = job["id"] self._run_offline_function(self.reid_function_id, data, self.user) @@ -1242,34 +1450,37 @@ def test_can_run_offline_reid_function_on_whole_job(self): self.assertEqual( [ { - 'frame': job["start_frame"], - 'shapes': [ - { 'frame': frame, 'outside': frame > job["stop_frame"] } + "frame": job["start_frame"], + "shapes": [ + {"frame": frame, "outside": frame > job["stop_frame"]} for frame in requested_frame_range if frame in range(job["start_frame"], job["stop_frame"] + self.segment_size) - ] + ], } ], [ { - 'frame': track['frame'], - 'shapes': [ - filter_dict(shape, keep=['frame', 'outside']) - for shape in track["shapes"] - ] + "frame": track["frame"], + "shapes": [ + filter_dict(shape, keep=["frame", "outside"]) for shape in track["shapes"] + ], } - for track in annotations['tracks'] - ] + for track in annotations["tracks"] + ], ) def test_can_run_offline_detector_function_on_whole_gt_job(self): requested_frame_range = self.task_rel_frame_range[::3] - response = self._post_request("/api/jobs", self.admin, data={ - "type": "ground_truth", - "task_id": self.task["id"], - "frame_selection_method": "manual", - "frames": list(requested_frame_range), - }) + response = self._post_request( + "/api/jobs", + self.admin, + data={ + "type": "ground_truth", + "task_id": self.task["id"], + "frame_selection_method": "manual", + "frames": list(requested_frame_range), + }, + ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) job = response.json() @@ -1285,49 +1496,54 @@ def test_can_run_offline_detector_function_on_whole_gt_job(self): self.assertEqual(len(annotations["tracks"]), 0) self.assertEqual( - { frame: 1 for frame in requested_frame_range }, - Counter(a["frame"] for a in annotations["shapes"]) + {frame: 1 for frame in requested_frame_range}, + Counter(a["frame"] for a in annotations["shapes"]), ) response = self._get_request(f'/api/tasks/{self.task["id"]}/annotations', self.admin) self.assertEqual(response.status_code, status.HTTP_200_OK) annotations = response.json() - self.assertEqual(annotations, {'version': 0, 'tags': [], 'shapes': [], 'tracks': []}) + self.assertEqual(annotations, {"version": 0, "tags": [], "shapes": [], "tracks": []}) def test_can_run_offline_reid_function_on_whole_gt_job(self): requested_frame_range = self.task_rel_frame_range[::3] - response = self._post_request("/api/jobs", self.admin, data={ - "type": "ground_truth", - "task_id": self.task["id"], - "frame_selection_method": "manual", - "frames": list(requested_frame_range), - }) + response = self._post_request( + "/api/jobs", + self.admin, + data={ + "type": "ground_truth", + "task_id": self.task["id"], + "frame_selection_method": "manual", + "frames": list(requested_frame_range), + }, + ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) job = response.json() # Add starting shapes to be tracked on following frames shape_template = { - 'attributes': [], - 'group': None, - 'label_id': self.labels[0]["id"], - 'occluded': False, - 'points': [0, 5, 5, 0], - 'source': 'manual', - 'type': 'rectangle', - 'z_order': 0, + "attributes": [], + "group": None, + "label_id": self.labels[0]["id"], + "occluded": False, + "points": [0, 5, 5, 0], + "source": "manual", + "type": "rectangle", + "z_order": 0, } - response = self._put_request(f'/api/jobs/{job["id"]}/annotations', self.admin, data={ - 'tags': [], - 'shapes': [ - { 'frame': frame, **shape_template } - for frame in requested_frame_range - ], - 'tracks': [] - }) + response = self._put_request( + f'/api/jobs/{job["id"]}/annotations', + self.admin, + data={ + "tags": [], + "shapes": [{"frame": frame, **shape_template} for frame in requested_frame_range], + "tracks": [], + }, + ) self.assertEqual(response.status_code, status.HTTP_200_OK) data = self.common_request_data.copy() - data["cleanup"] = False # cleanup is not compatible with reid + data["cleanup"] = False # cleanup is not compatible with reid data["job"] = job["id"] self._run_offline_function(self.reid_function_id, data, self.user) @@ -1340,38 +1556,41 @@ def test_can_run_offline_reid_function_on_whole_gt_job(self): self.assertEqual( [ { - 'frame': job["start_frame"], - 'shapes': [ - { 'frame': frame, 'outside': frame > job["stop_frame"] } + "frame": job["start_frame"], + "shapes": [ + {"frame": frame, "outside": frame > job["stop_frame"]} for frame in requested_frame_range if frame in range(job["start_frame"], job["stop_frame"] + self.segment_size) - ] + ], } ], [ { - 'frame': track['frame'], - 'shapes': [ - filter_dict(shape, keep=['frame', 'outside']) - for shape in track["shapes"] - ] + "frame": track["frame"], + "shapes": [ + filter_dict(shape, keep=["frame", "outside"]) for shape in track["shapes"] + ], } - for track in annotations['tracks'] - ] + for track in annotations["tracks"] + ], ) response = self._get_request(f'/api/tasks/{self.task["id"]}/annotations', self.admin) self.assertEqual(response.status_code, status.HTTP_200_OK) annotations = response.json() - self.assertEqual(annotations, {'version': 0, 'tags': [], 'shapes': [], 'tracks': []}) + self.assertEqual(annotations, {"version": 0, "tags": [], "shapes": [], "tracks": []}) def test_offline_function_run_on_task_does_not_affect_gt_job(self): - response = self._post_request("/api/jobs", self.admin, data={ - "type": "ground_truth", - "task_id": self.task["id"], - "frame_selection_method": "manual", - "frames": list(self.task_rel_frame_range[::3]), - }) + response = self._post_request( + "/api/jobs", + self.admin, + data={ + "type": "ground_truth", + "task_id": self.task["id"], + "frame_selection_method": "manual", + "frames": list(self.task_rel_frame_range[::3]), + }, + ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) job = response.json() @@ -1387,14 +1606,14 @@ def test_offline_function_run_on_task_does_not_affect_gt_job(self): requested_frame_range = self.task_rel_frame_range self.assertEqual( - { frame: 1 for frame in requested_frame_range }, - Counter(a["frame"] for a in annotations["shapes"]) + {frame: 1 for frame in requested_frame_range}, + Counter(a["frame"] for a in annotations["shapes"]), ) response = self._get_request(f'/api/jobs/{job["id"]}/annotations', self.admin) self.assertEqual(response.status_code, status.HTTP_200_OK) annotations = response.json() - self.assertEqual(annotations, {'version': 0, 'tags': [], 'shapes': [], 'tracks': []}) + self.assertEqual(annotations, {"version": 0, "tags": [], "shapes": [], "tracks": []}) def test_can_run_online_function_on_valid_task_frame(self): data = self.common_request_data.copy() @@ -1445,70 +1664,87 @@ class Issue4996_Cases(_LambdaTestCaseBase): # This requires to pass the job id in the call request. def _create_org(self, *, owner: int, members: dict[int, str] = None) -> dict: - org = self._post_request('/api/organizations', user=owner, data={ - "slug": "testorg", - "name": "test Org", - }) + org = self._post_request( + "/api/organizations", + user=owner, + data={ + "slug": "testorg", + "name": "test Org", + }, + ) assert org.status_code == status.HTTP_201_CREATED org = org.json() for uid, role in members.items(): - user = self._get_request('/api/users/self', user=uid) + user = self._get_request("/api/users/self", user=uid) assert user.status_code == status.HTTP_200_OK user = user.json() - invitation = self._post_request('/api/invitations', user=owner, data={ - 'email': user['email'], - 'role': role, - }, org_id=org['id']) + invitation = self._post_request( + "/api/invitations", + user=owner, + data={ + "email": user["email"], + "role": role, + }, + org_id=org["id"], + ) assert invitation.status_code == status.HTTP_201_CREATED return org - def _set_task_assignee(self, task: int, assignee: Optional[int], *, - org_id: Optional[int] = None): - response = self._patch_request(f'/api/tasks/{task}', user=self.admin, data={ - 'assignee_id': assignee, - }, org_id=org_id) + def _set_task_assignee( + self, task: int, assignee: Optional[int], *, org_id: Optional[int] = None + ): + response = self._patch_request( + f"/api/tasks/{task}", + user=self.admin, + data={ + "assignee_id": assignee, + }, + org_id=org_id, + ) assert response.status_code == status.HTTP_200_OK - def _set_job_assignee(self, job: int, assignee: Optional[int], *, - org_id: Optional[int] = None): - response = self._patch_request(f'/api/jobs/{job}', user=self.admin, data={ - 'assignee': assignee, - }, org_id=org_id) + def _set_job_assignee(self, job: int, assignee: Optional[int], *, org_id: Optional[int] = None): + response = self._patch_request( + f"/api/jobs/{job}", + user=self.admin, + data={ + "assignee": assignee, + }, + org_id=org_id, + ) assert response.status_code == status.HTTP_200_OK def setUp(self): super().setUp() - self.org = self._create_org(owner=self.admin, members={self.user: 'worker'}) + self.org = self._create_org(owner=self.admin, members={self.user: "worker"}) - task = self._create_task(task_spec={ - 'name': 'test_task', - 'labels': [{'name': 'car'}], - 'segment_size': 2 - }, + task = self._create_task( + task_spec={"name": "test_task", "labels": [{"name": "car"}], "segment_size": 2}, data=self._generate_task_images(6), owner=self.admin, - org_id=self.org['id'], + org_id=self.org["id"], ) self.task = task - jobs = get_paginated_collection(lambda page: - self._get_request( + jobs = get_paginated_collection( + lambda page: self._get_request( f"/api/jobs?task_id={self.task['id']}&page={page}", - self.admin, org_id=self.org['id'] + self.admin, + org_id=self.org["id"], ) ) self.job = jobs[1] self.common_request_data = { - "task": self.task['id'], + "task": self.task["id"], "frame": 0, "cleanup": True, "mapping": { - "car": { "name": "car" }, + "car": {"name": "car"}, }, } @@ -1516,75 +1752,70 @@ def setUp(self): def _get_valid_job_request_data(self): data = self.common_request_data.copy() - data.update({ - "job": self.job['id'], - "frame": 2 - }) + data.update({"job": self.job["id"], "frame": 2}) return data def _get_invalid_job_request_data(self): data = self.common_request_data.copy() - data.update({ - "job": self.job['id'], - "frame": 0 - }) + data.update({"job": self.job["id"], "frame": 0}) return data - def test_can_call_function_for_job_worker_in_org__deny_unassigned_worker_with_task_request(self): + def test_can_call_function_for_job_worker_in_org__deny_unassigned_worker_with_task_request( + self, + ): data = self.common_request_data.copy() with self.subTest(job=None, assignee=None): - response = self._post_request(self.function_url, self.user, data, - org_id=self.org['id']) + response = self._post_request(self.function_url, self.user, data, org_id=self.org["id"]) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_can_call_function_for_job_worker_in_org__deny_unassigned_worker_with_job_request(self): data = self._get_valid_job_request_data() - with self.subTest(job='defined', assignee=None): - response = self._post_request(self.function_url, self.user, data, - org_id=self.org['id']) + with self.subTest(job="defined", assignee=None): + response = self._post_request(self.function_url, self.user, data, org_id=self.org["id"]) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_can_call_function_for_job_worker_in_org__allow_task_assigned_worker_with_task_request(self): - self._set_task_assignee(self.task['id'], self.user.id, org_id=self.org['id']) + def test_can_call_function_for_job_worker_in_org__allow_task_assigned_worker_with_task_request( + self, + ): + self._set_task_assignee(self.task["id"], self.user.id, org_id=self.org["id"]) data = self.common_request_data.copy() - with self.subTest(job=None, assignee='task'): - response = self._post_request(self.function_url, self.user, data, - org_id=self.org['id']) + with self.subTest(job=None, assignee="task"): + response = self._post_request(self.function_url, self.user, data, org_id=self.org["id"]) self.assertEqual(response.status_code, status.HTTP_200_OK) - def test_can_call_function_for_job_worker_in_org__deny_job_assigned_worker_with_task_request(self): - self._set_job_assignee(self.job['id'], self.user.id, org_id=self.org['id']) + def test_can_call_function_for_job_worker_in_org__deny_job_assigned_worker_with_task_request( + self, + ): + self._set_job_assignee(self.job["id"], self.user.id, org_id=self.org["id"]) data = self.common_request_data.copy() - with self.subTest(job=None, assignee='job'): - response = self._post_request(self.function_url, self.user, data, - org_id=self.org['id']) + with self.subTest(job=None, assignee="job"): + response = self._post_request(self.function_url, self.user, data, org_id=self.org["id"]) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_can_call_function_for_job_worker_in_org__allow_job_assigned_worker_with_job_request(self): - self._set_job_assignee(self.job['id'], self.user.id, org_id=self.org['id']) + def test_can_call_function_for_job_worker_in_org__allow_job_assigned_worker_with_job_request( + self, + ): + self._set_job_assignee(self.job["id"], self.user.id, org_id=self.org["id"]) data = self._get_valid_job_request_data() - with self.subTest(job='defined', assignee='job'): - response = self._post_request(self.function_url, self.user, data, - org_id=self.org['id']) + with self.subTest(job="defined", assignee="job"): + response = self._post_request(self.function_url, self.user, data, org_id=self.org["id"]) self.assertEqual(response.status_code, status.HTTP_200_OK) def test_can_check_job_boundaries_in_function_call__fail_for_frame_outside_job(self): - self._set_job_assignee(self.job['id'], self.user.id, org_id=self.org['id']) + self._set_job_assignee(self.job["id"], self.user.id, org_id=self.org["id"]) data = self._get_invalid_job_request_data() - with self.subTest(job='defined', frame='outside'): - response = self._post_request(self.function_url, self.user, data, - org_id=self.org['id']) + with self.subTest(job="defined", frame="outside"): + response = self._post_request(self.function_url, self.user, data, org_id=self.org["id"]) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) def test_can_check_job_boundaries_in_function_call__ok_for_frame_inside_job(self): - self._set_job_assignee(self.job['id'], self.user.id, org_id=self.org['id']) + self._set_job_assignee(self.job["id"], self.user.id, org_id=self.org["id"]) data = self._get_valid_job_request_data() - with self.subTest(job='defined', frame='inside'): - response = self._post_request(self.function_url, self.user, data, - org_id=self.org['id']) + with self.subTest(job="defined", frame="inside"): + response = self._post_request(self.function_url, self.user, data, org_id=self.org["id"]) self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/cvat/apps/lambda_manager/urls.py b/cvat/apps/lambda_manager/urls.py index 6dae0edaca76..261592a9f469 100644 --- a/cvat/apps/lambda_manager/urls.py +++ b/cvat/apps/lambda_manager/urls.py @@ -12,9 +12,9 @@ # I want to "call" my functions. To do that need to map my call method to # POST (like get HTTP method is mapped to list(...)). One way is to implement # own CustomRouter. But it is simpler just patch the router instance here. -router.routes[2].mapping.update({'post': 'call'}) -router.register('functions', views.FunctionViewSet, basename='lambda_function') -router.register('requests', views.RequestViewSet, basename='lambda_request') +router.routes[2].mapping.update({"post": "call"}) +router.register("functions", views.FunctionViewSet, basename="lambda_function") +router.register("requests", views.RequestViewSet, basename="lambda_request") # GET /api/lambda/functions - get list of functions # GET /api/lambda/functions/ - get information about the function @@ -24,6 +24,4 @@ # GET /api/lambda/requests - get list of requests # GET /api/lambda/requests/ - get status of the request # DEL /api/lambda/requests/ - cancel a request (don't delete) -urlpatterns = [ - path('api/lambda/', include(router.urls)) -] +urlpatterns = [path("api/lambda/", include(router.urls))] diff --git a/cvat/apps/lambda_manager/views.py b/cvat/apps/lambda_manager/views.py index 3df3476bf083..465414e243a5 100644 --- a/cvat/apps/lambda_manager/views.py +++ b/cvat/apps/lambda_manager/views.py @@ -61,22 +61,33 @@ slogger = ServerLogManager(__name__) + class LambdaGateway: - NUCLIO_ROOT_URL = '/api/functions' - - def _http(self, method="get", scheme=None, host=None, port=None, - function_namespace=None, url=None, headers=None, data=None): - NUCLIO_GATEWAY = '{}://{}:{}'.format( - scheme or settings.NUCLIO['SCHEME'], - host or settings.NUCLIO['HOST'], - port or settings.NUCLIO['PORT']) - NUCLIO_FUNCTION_NAMESPACE = function_namespace or settings.NUCLIO['FUNCTION_NAMESPACE'] - NUCLIO_TIMEOUT = settings.NUCLIO['DEFAULT_TIMEOUT'] + NUCLIO_ROOT_URL = "/api/functions" + + def _http( + self, + method="get", + scheme=None, + host=None, + port=None, + function_namespace=None, + url=None, + headers=None, + data=None, + ): + NUCLIO_GATEWAY = "{}://{}:{}".format( + scheme or settings.NUCLIO["SCHEME"], + host or settings.NUCLIO["HOST"], + port or settings.NUCLIO["PORT"], + ) + NUCLIO_FUNCTION_NAMESPACE = function_namespace or settings.NUCLIO["FUNCTION_NAMESPACE"] + NUCLIO_TIMEOUT = settings.NUCLIO["DEFAULT_TIMEOUT"] extra_headers = { - 'x-nuclio-project-name': 'cvat', - 'x-nuclio-function-namespace': NUCLIO_FUNCTION_NAMESPACE, - 'x-nuclio-invoke-via': 'domain-name', - 'X-Nuclio-Invoke-Timeout': f"{NUCLIO_TIMEOUT}s", + "x-nuclio-project-name": "cvat", + "x-nuclio-function-namespace": NUCLIO_FUNCTION_NAMESPACE, + "x-nuclio-invoke-via": "domain-name", + "X-Nuclio-Invoke-Timeout": f"{NUCLIO_TIMEOUT}s", } if headers: extra_headers.update(headers) @@ -87,8 +98,9 @@ def _http(self, method="get", scheme=None, host=None, port=None, url = NUCLIO_GATEWAY with make_requests_session() as session: - reply = session.request(method, url, headers=extra_headers, - timeout=NUCLIO_TIMEOUT, json=data) + reply = session.request( + method, url, headers=extra_headers, timeout=NUCLIO_TIMEOUT, json=data + ) reply.raise_for_status() response = reply.json() @@ -103,32 +115,33 @@ def list(self): slogger.glob.error("Failed to parse lambda function metadata", exc_info=True) def get(self, func_id): - data = self._http(url=self.NUCLIO_ROOT_URL + '/' + func_id) + data = self._http(url=self.NUCLIO_ROOT_URL + "/" + func_id) response = LambdaFunction(self, data) return response def invoke(self, func, payload): invoke_method = { - 'dashboard': self._invoke_via_dashboard, - 'direct': self._invoke_directly, + "dashboard": self._invoke_via_dashboard, + "direct": self._invoke_directly, } - return invoke_method[settings.NUCLIO['INVOKE_METHOD']](func, payload) + return invoke_method[settings.NUCLIO["INVOKE_METHOD"]](func, payload) def _invoke_via_dashboard(self, func, payload): - return self._http(method="post", url='/api/function_invocations', - data=payload, headers={ - 'x-nuclio-function-name': func.id, - 'x-nuclio-path': '/' - }) + return self._http( + method="post", + url="/api/function_invocations", + data=payload, + headers={"x-nuclio-function-name": func.id, "x-nuclio-path": "/"}, + ) def _invoke_directly(self, func, payload): # host.docker.internal for Linux will work only with Docker 20.10+ - NUCLIO_TIMEOUT = settings.NUCLIO['DEFAULT_TIMEOUT'] - if os.path.exists('/.dockerenv'): # inside a docker container - url = f'http://host.docker.internal:{func.port}' + NUCLIO_TIMEOUT = settings.NUCLIO["DEFAULT_TIMEOUT"] + if os.path.exists("/.dockerenv"): # inside a docker container + url = f"http://host.docker.internal:{func.port}" else: - url = f'http://localhost:{func.port}' + url = f"http://localhost:{func.port}" with make_requests_session() as session: reply = session.post(url, timeout=NUCLIO_TIMEOUT, json=payload) @@ -137,105 +150,119 @@ def _invoke_directly(self, func, payload): return response + class InvalidFunctionMetadataError(Exception): pass + class LambdaFunction: FRAME_PARAMETERS = ( - ('frame', 'frame'), - ('frame0', 'start frame'), - ('frame1', 'end frame'), + ("frame", "frame"), + ("frame0", "start frame"), + ("frame1", "end frame"), ) def __init__(self, gateway, data): # ID of the function (e.g. omz.public.yolo-v3) - self.id = data['metadata']['name'] + self.id = data["metadata"]["name"] # type of the function (e.g. detector, interactor) - meta_anno = data['metadata']['annotations'] - kind = meta_anno.get('type') + meta_anno = data["metadata"]["annotations"] + kind = meta_anno.get("type") try: self.kind = FunctionKind(kind) except ValueError as e: raise InvalidFunctionMetadataError( - f"{self.id} lambda function has unknown type: {kind!r}") from e + f"{self.id} lambda function has unknown type: {kind!r}" + ) from e # dictionary of labels for the function (e.g. car, person) - spec = json.loads(meta_anno.get('spec') or '[]') + spec = json.loads(meta_anno.get("spec") or "[]") def parse_labels(spec): def parse_attributes(attrs_spec): - parsed_attributes = [{ - 'name': attr['name'], - 'input_type': attr['input_type'], - 'values': attr['values'], - } for attr in attrs_spec] - - if len(parsed_attributes) != len({attr['name'] for attr in attrs_spec}): + parsed_attributes = [ + { + "name": attr["name"], + "input_type": attr["input_type"], + "values": attr["values"], + } + for attr in attrs_spec + ] + + if len(parsed_attributes) != len({attr["name"] for attr in attrs_spec}): raise InvalidFunctionMetadataError( - f"{self.id} lambda function has non-unique attributes") + f"{self.id} lambda function has non-unique attributes" + ) return parsed_attributes parsed_labels = [] for label in spec: parsed_label = { - 'name': label['name'], - 'type': label.get('type', 'unknown'), - 'attributes': parse_attributes(label.get('attributes', [])) + "name": label["name"], + "type": label.get("type", "unknown"), + "attributes": parse_attributes(label.get("attributes", [])), } - if parsed_label['type'] == 'skeleton': - parsed_label.update({ - 'sublabels': parse_labels(label['sublabels']), - 'svg': label['svg'] - }) + if parsed_label["type"] == "skeleton": + parsed_label.update( + {"sublabels": parse_labels(label["sublabels"]), "svg": label["svg"]} + ) parsed_labels.append(parsed_label) - if len(parsed_labels) != len({label['name'] for label in spec}): + if len(parsed_labels) != len({label["name"] for label in spec}): raise InvalidFunctionMetadataError( - f"{self.id} lambda function has non-unique labels") + f"{self.id} lambda function has non-unique labels" + ) return parsed_labels self.labels = parse_labels(spec) # mapping of labels and corresponding supported attributes - self.func_attributes = {item['name']: item.get('attributes', []) for item in spec} + self.func_attributes = {item["name"]: item.get("attributes", []) for item in spec} for label, attributes in self.func_attributes.items(): - if len([attr['name'] for attr in attributes]) != len(set([attr['name'] for attr in attributes])): + if len([attr["name"] for attr in attributes]) != len( + set([attr["name"] for attr in attributes]) + ): raise InvalidFunctionMetadataError( - "`{}` lambda function has non-unique attributes for label {}".format(self.id, label)) + "`{}` lambda function has non-unique attributes for label {}".format( + self.id, label + ) + ) # description of the function - self.description = data['spec']['description'] + self.description = data["spec"]["description"] # http port to access the serverless function self.port = data["status"].get("httpPort") # display name for the function - self.name = meta_anno.get('name', self.id) - self.min_pos_points = int(meta_anno.get('min_pos_points', 1)) - self.min_neg_points = int(meta_anno.get('min_neg_points', -1)) - self.startswith_box = bool(meta_anno.get('startswith_box', False)) - self.startswith_box_optional = bool(meta_anno.get('startswith_box_optional', False)) - self.animated_gif = meta_anno.get('animated_gif', '') - self.version = int(meta_anno.get('version', '1')) - self.help_message = meta_anno.get('help_message', '') + self.name = meta_anno.get("name", self.id) + self.min_pos_points = int(meta_anno.get("min_pos_points", 1)) + self.min_neg_points = int(meta_anno.get("min_neg_points", -1)) + self.startswith_box = bool(meta_anno.get("startswith_box", False)) + self.startswith_box_optional = bool(meta_anno.get("startswith_box_optional", False)) + self.animated_gif = meta_anno.get("animated_gif", "") + self.version = int(meta_anno.get("version", "1")) + self.help_message = meta_anno.get("help_message", "") self.gateway = gateway def to_dict(self): response = { - 'id': self.id, - 'kind': str(self.kind), - 'labels_v2': self.labels, - 'description': self.description, - 'name': self.name, - 'version': self.version + "id": self.id, + "kind": str(self.kind), + "labels_v2": self.labels, + "description": self.description, + "name": self.name, + "version": self.version, } if self.kind is FunctionKind.INTERACTOR: - response.update({ - 'min_pos_points': self.min_pos_points, - 'min_neg_points': self.min_neg_points, - 'startswith_box': self.startswith_box, - 'startswith_box_optional': self.startswith_box_optional, - 'help_message': self.help_message, - 'animated_gif': self.animated_gif - }) + response.update( + { + "min_pos_points": self.min_pos_points, + "min_neg_points": self.min_neg_points, + "startswith_box": self.startswith_box, + "startswith_box_optional": self.startswith_box_optional, + "help_message": self.help_message, + "animated_gif": self.animated_gif, + } + ) return response @@ -246,62 +273,75 @@ def invoke( *, db_job: Optional[Job] = None, is_interactive: Optional[bool] = False, - request: Optional[Request] = None + request: Optional[Request] = None, ): if db_job is not None and db_job.get_task_id() != db_task.id: - raise ValidationError("Job task id does not match task id", - code=status.HTTP_400_BAD_REQUEST + raise ValidationError( + "Job task id does not match task id", code=status.HTTP_400_BAD_REQUEST ) payload = {} - data = {k: v for k,v in data.items() if v is not None} + data = {k: v for k, v in data.items() if v is not None} def mandatory_arg(name: str) -> Any: try: return data[name] except KeyError: raise ValidationError( - "`{}` lambda function was called without mandatory argument: {}" - .format(self.id, name), - code=status.HTTP_400_BAD_REQUEST) + "`{}` lambda function was called without mandatory argument: {}".format( + self.id, name + ), + code=status.HTTP_400_BAD_REQUEST, + ) threshold = data.get("threshold") if threshold: - payload.update({ "threshold": threshold }) + payload.update({"threshold": threshold}) mapping = data.get("mapping", {}) model_labels = self.labels task_labels = db_task.get_labels(prefetch=True) def labels_compatible(model_label: dict, task_label: Label) -> bool: - model_type = model_label['type'] + model_type = model_label["type"] db_type = task_label.type compatible_types = [[ShapeType.MASK, ShapeType.POLYGON]] - return model_type == db_type or \ - (db_type == 'any' and model_type != 'skeleton') or \ - (model_type == 'unknown' and db_type != 'skeleton') or \ - any([model_type in compatible and db_type in compatible for compatible in compatible_types]) + return ( + model_type == db_type + or (db_type == "any" and model_type != "skeleton") + or (model_type == "unknown" and db_type != "skeleton") + or any( + [ + model_type in compatible and db_type in compatible + for compatible in compatible_types + ] + ) + ) def make_default_mapping(model_labels, task_labels): mapping_by_default = {} for model_label in model_labels: for task_label in task_labels: - if task_label.name == model_label['name'] and labels_compatible(model_label, task_label): + if task_label.name == model_label["name"] and labels_compatible( + model_label, task_label + ): attributes_default_mapping = {} - for model_attr in model_label.get('attributes', {}): + for model_attr in model_label.get("attributes", {}): for db_attr in task_label.attributespec_set.all(): - if db_attr.name == model_attr['name']: - attributes_default_mapping[model_attr['name']] = db_attr.name + if db_attr.name == model_attr["name"]: + attributes_default_mapping[model_attr["name"]] = db_attr.name - mapping_by_default[model_label['name']] = { - 'name': task_label.name, - 'attributes': attributes_default_mapping, + mapping_by_default[model_label["name"]] = { + "name": task_label.name, + "attributes": attributes_default_mapping, } - if model_label['type'] == 'skeleton' and task_label.type == 'skeleton': - mapping_by_default[model_label['name']]['sublabels'] = make_default_mapping( - model_label['sublabels'], - task_label.sublabels.all(), + if model_label["type"] == "skeleton" and task_label.type == "skeleton": + mapping_by_default[model_label["name"]]["sublabels"] = ( + make_default_mapping( + model_label["sublabels"], + task_label.sublabels.all(), + ) ) return mapping_by_default @@ -309,39 +349,43 @@ def make_default_mapping(model_labels, task_labels): def update_mapping(_mapping, _model_labels, _db_labels): copy = deepcopy(_mapping) for model_label_name, mapping_item in copy.items(): - md_label = next(filter(lambda x: x['name'] == model_label_name, _model_labels)) - db_label = next(filter(lambda x: x.name == mapping_item['name'], _db_labels)) - mapping_item.setdefault('attributes', {}) - mapping_item['md_label'] = md_label - mapping_item['db_label'] = db_label - if md_label['type'] == 'skeleton' and db_label.type == 'skeleton': - mapping_item['sublabels'] = update_mapping( - mapping_item['sublabels'], - md_label['sublabels'], - db_label.sublabels.all() + md_label = next(filter(lambda x: x["name"] == model_label_name, _model_labels)) + db_label = next(filter(lambda x: x.name == mapping_item["name"], _db_labels)) + mapping_item.setdefault("attributes", {}) + mapping_item["md_label"] = md_label + mapping_item["db_label"] = db_label + if md_label["type"] == "skeleton" and db_label.type == "skeleton": + mapping_item["sublabels"] = update_mapping( + mapping_item["sublabels"], md_label["sublabels"], db_label.sublabels.all() ) return copy def validate_labels_mapping(_mapping, _model_labels, _db_labels): def validate_attributes_mapping(attributes_mapping, model_attributes, db_attributes): db_attr_names = [attr.name for attr in db_attributes] - model_attr_names = [attr['name'] for attr in model_attributes] + model_attr_names = [attr["name"] for attr in model_attributes] for model_attr in attributes_mapping: task_attr = attributes_mapping[model_attr] if model_attr not in model_attr_names: - raise ValidationError(f'Invalid mapping. Unknown model attribute "{model_attr}"') + raise ValidationError( + f'Invalid mapping. Unknown model attribute "{model_attr}"' + ) if task_attr not in db_attr_names: - raise ValidationError(f'Invalid mapping. Unknown db attribute "{task_attr}"') + raise ValidationError( + f'Invalid mapping. Unknown db attribute "{task_attr}"' + ) for model_label_name, mapping_item in _mapping.items(): - db_label_name = mapping_item['name'] + db_label_name = mapping_item["name"] md_label = None db_label = None try: - md_label = next(x for x in _model_labels if x['name'] == model_label_name) + md_label = next(x for x in _model_labels if x["name"] == model_label_name) except StopIteration: - raise ValidationError(f'Invalid mapping. Unknown model label "{model_label_name}"') + raise ValidationError( + f'Invalid mapping. Unknown model label "{model_label_name}"' + ) try: db_label = next(x for x in _db_labels if x.name == db_label_name) @@ -350,26 +394,24 @@ def validate_attributes_mapping(attributes_mapping, model_attributes, db_attribu if not labels_compatible(md_label, db_label): raise ValidationError( - f'Invalid mapping. Model label "{model_label_name}" and' + \ - f' database label "{db_label_name}" are not compatible' + f'Invalid mapping. Model label "{model_label_name}" and' + + f' database label "{db_label_name}" are not compatible' ) validate_attributes_mapping( - mapping_item.get('attributes', {}), - md_label['attributes'], - db_label.attributespec_set.all() + mapping_item.get("attributes", {}), + md_label["attributes"], + db_label.attributespec_set.all(), ) - if md_label['type'] == 'skeleton' and db_label.type == 'skeleton': - if 'sublabels' not in mapping_item: + if md_label["type"] == "skeleton" and db_label.type == "skeleton": + if "sublabels" not in mapping_item: raise ValidationError( f'Mapping for elements was not specified in skeleton "{model_label_name}" ' ) validate_labels_mapping( - mapping_item['sublabels'], - md_label['sublabels'], - db_label.sublabels.all() + mapping_item["sublabels"], md_label["sublabels"], db_label.sublabels.all() ) if not mapping: @@ -391,44 +433,46 @@ def validate_attributes_mapping(attributes_mapping, model_attributes, db_attribu abs_frame_id = data_start_frame + data[key] * step if not db_job.segment.contains_frame(abs_frame_id): - raise ValidationError(f"The {desc} is outside the job range", - code=status.HTTP_400_BAD_REQUEST) - + raise ValidationError( + f"The {desc} is outside the job range", code=status.HTTP_400_BAD_REQUEST + ) if self.kind == FunctionKind.DETECTOR: - payload.update({ - "image": self._get_image(db_task, mandatory_arg("frame")) - }) + payload.update({"image": self._get_image(db_task, mandatory_arg("frame"))}) elif self.kind == FunctionKind.INTERACTOR: - payload.update({ - "image": self._get_image(db_task, mandatory_arg("frame")), - "pos_points": mandatory_arg("pos_points"), - "neg_points": mandatory_arg("neg_points"), - "obj_bbox": data.get("obj_bbox", None) - }) + payload.update( + { + "image": self._get_image(db_task, mandatory_arg("frame")), + "pos_points": mandatory_arg("pos_points"), + "neg_points": mandatory_arg("neg_points"), + "obj_bbox": data.get("obj_bbox", None), + } + ) elif self.kind == FunctionKind.REID: - payload.update({ - "image0": self._get_image(db_task, mandatory_arg("frame0")), - "image1": self._get_image(db_task, mandatory_arg("frame1")), - "boxes0": mandatory_arg("boxes0"), - "boxes1": mandatory_arg("boxes1") - }) + payload.update( + { + "image0": self._get_image(db_task, mandatory_arg("frame0")), + "image1": self._get_image(db_task, mandatory_arg("frame1")), + "boxes0": mandatory_arg("boxes0"), + "boxes1": mandatory_arg("boxes1"), + } + ) max_distance = data.get("max_distance") if max_distance: - payload.update({ - "max_distance": max_distance - }) + payload.update({"max_distance": max_distance}) elif self.kind == FunctionKind.TRACKER: - payload.update({ - "image": self._get_image(db_task, mandatory_arg("frame")), - "shapes": data.get("shapes", []), - "states": data.get("states", []) - }) + payload.update( + { + "image": self._get_image(db_task, mandatory_arg("frame")), + "shapes": data.get("shapes", []), + "states": data.get("states", []), + } + ) else: raise ValidationError( - '`{}` lambda function has incorrect type: {}' - .format(self.id, self.kind), - code=status.HTTP_500_INTERNAL_SERVER_ERROR) + "`{}` lambda function has incorrect type: {}".format(self.id, self.kind), + code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) if is_interactive and request: interactive_function_call_signal.send(sender=self, request=request) @@ -456,41 +500,38 @@ def check_attr_value(value, db_attr): def transform_attributes(input_attributes, attr_mapping, db_attributes): attributes = [] for attr in input_attributes: - if attr['name'] not in attr_mapping: + if attr["name"] not in attr_mapping: continue - db_attr_name = attr_mapping[attr['name']] - db_attr = next(filter(lambda x: x['name'] == db_attr_name, db_attributes), None) - if db_attr is not None and check_attr_value(attr['value'], db_attr): - attributes.append({ - 'name': db_attr['name'], - 'value': attr['value'] - }) + db_attr_name = attr_mapping[attr["name"]] + db_attr = next(filter(lambda x: x["name"] == db_attr_name, db_attributes), None) + if db_attr is not None and check_attr_value(attr["value"], db_attr): + attributes.append({"name": db_attr["name"], "value": attr["value"]}) return attributes if self.kind == FunctionKind.DETECTOR: for item in response: - item_label = item['label'] + item_label = item["label"] if item_label not in mapping: continue - db_label = mapping[item_label]['db_label'] - item['label'] = db_label.name - item['attributes'] = transform_attributes( - item.get('attributes', {}), - mapping[item_label]['attributes'], - db_label.attributespec_set.values() + db_label = mapping[item_label]["db_label"] + item["label"] = db_label.name + item["attributes"] = transform_attributes( + item.get("attributes", {}), + mapping[item_label]["attributes"], + db_label.attributespec_set.values(), ) - if 'elements' in item: - sublabels = mapping[item_label]['sublabels'] - item['elements'] = [x for x in item['elements'] if x['label'] in sublabels] - for element in item['elements']: - element_label = element['label'] - db_label = sublabels[element_label]['db_label'] - element['label'] = db_label.name - element['attributes'] = transform_attributes( - element.get('attributes', {}), - sublabels[element_label]['attributes'], - db_label.attributespec_set.values() + if "elements" in item: + sublabels = mapping[item_label]["sublabels"] + item["elements"] = [x for x in item["elements"] if x["label"] in sublabels] + for element in item["elements"]: + element_label = element["label"] + db_label = sublabels[element_label]["db_label"] + element["label"] = db_label.name + element["attributes"] = transform_attributes( + element.get("attributes", {}), + sublabels[element_label]["attributes"], + db_label.attributespec_set.values(), ) response_filtered.append(item) response = response_filtered @@ -501,7 +542,8 @@ def _get_image(self, db_task, frame): frame_provider = TaskFrameProvider(db_task) image = frame_provider.get_frame(frame) - return base64.b64encode(image.data.getvalue()).decode('utf-8') + return base64.b64encode(image.data.getvalue()).decode("utf-8") + class LambdaQueue: RESULT_TTL = timedelta(minutes=30) @@ -513,19 +555,29 @@ def _get_queue(self): def get_jobs(self): queue = self._get_queue() # Only failed jobs are not included in the list below. - job_ids = set(queue.get_job_ids() + - queue.started_job_registry.get_job_ids() + - queue.finished_job_registry.get_job_ids() + - queue.scheduled_job_registry.get_job_ids() + - queue.deferred_job_registry.get_job_ids()) + job_ids = set( + queue.get_job_ids() + + queue.started_job_registry.get_job_ids() + + queue.finished_job_registry.get_job_ids() + + queue.scheduled_job_registry.get_job_ids() + + queue.deferred_job_registry.get_job_ids() + ) jobs = queue.job_class.fetch_many(job_ids, queue.connection) return [LambdaJob(job) for job in jobs if job and job.meta.get("lambda")] - def enqueue(self, - lambda_func, threshold, task, mapping, cleanup, conv_mask_to_poly, max_distance, request, + def enqueue( + self, + lambda_func, + threshold, + task, + mapping, + cleanup, + conv_mask_to_poly, + max_distance, + request, *, - job: Optional[int] = None + job: Optional[int] = None, ) -> LambdaJob: queue = self._get_queue() rq_id = RQId(RequestAction.AUTOANNOTATE, RequestTarget.TASK, task).render() @@ -535,8 +587,10 @@ def enqueue(self, # protection. rq_job = queue.fetch_job(rq_id) - have_conflict = rq_job and \ - rq_job.get_status(refresh=False) not in {rq.job.JobStatus.FAILED, rq.job.JobStatus.FINISHED} + have_conflict = rq_job and rq_job.get_status(refresh=False) not in { + rq.job.JobStatus.FAILED, + rq.job.JobStatus.FINISHED, + } # There could be some jobs left over from before the current naming convention was adopted. # TODO: remove this check after a few releases. @@ -547,7 +601,8 @@ def enqueue(self, if have_conflict or have_legacy_conflict: raise ValidationError( "Only one running request is allowed for the same task #{}".format(task), - code=status.HTTP_409_CONFLICT) + code=status.HTTP_409_CONFLICT, + ) if rq_job: rq_job.delete() @@ -559,14 +614,13 @@ def enqueue(self, user_id = request.user.id with get_rq_lock_by_user(queue, user_id): - rq_job = queue.create_job(LambdaJob(None), + rq_job = queue.create_job( + LambdaJob(None), job_id=rq_id, meta={ **get_rq_job_meta( request, - db_obj=( - Job.objects.get(pk=job) if job else Task.objects.get(pk=task) - ), + db_obj=(Job.objects.get(pk=job) if job else Task.objects.get(pk=task)), ), RQJobMetaField.FUNCTION_ID: lambda_func.id, "lambda": True, @@ -579,7 +633,7 @@ def enqueue(self, "cleanup": cleanup, "conv_mask_to_poly": conv_mask_to_poly, "mapping": mapping, - "max_distance": max_distance + "max_distance": max_distance, }, depends_on=define_dependent_job(queue, user_id), result_ttl=self.RESULT_TTL.total_seconds(), @@ -594,36 +648,42 @@ def fetch_job(self, pk): queue = self._get_queue() rq_job = queue.fetch_job(pk) if rq_job is None or not rq_job.meta.get("lambda"): - raise ValidationError("{} lambda job is not found".format(pk), - code=status.HTTP_404_NOT_FOUND) + raise ValidationError( + "{} lambda job is not found".format(pk), code=status.HTTP_404_NOT_FOUND + ) return LambdaJob(rq_job) + class LambdaJob: def __init__(self, job): self.job = job def to_dict(self): lambda_func = self.job.kwargs.get("function") - dict_ = { + dict_ = { "id": self.job.id, "function": { "id": lambda_func.id if lambda_func else None, "threshold": self.job.kwargs.get("threshold"), "task": self.job.kwargs.get("task"), - **({ - "job": self.job.kwargs["job"], - } if self.job.kwargs.get("job") else {}) + **( + { + "job": self.job.kwargs["job"], + } + if self.job.kwargs.get("job") + else {} + ), }, "status": self.job.get_status(), - "progress": self.job.meta.get('progress', 0), + "progress": self.job.meta.get("progress", 0), "enqueued": self.job.enqueued_at, "started": self.job.started_at, "ended": self.job.ended_at, - "exc_info": self.job.exc_info + "exc_info": self.job.exc_info, } - if dict_['status'] == rq.job.JobStatus.DEFERRED: - dict_['status'] = rq.job.JobStatus.QUEUED.value + if dict_["status"] == rq.job.JobStatus.DEFERRED: + dict_["status"] = rq.job.JobStatus.QUEUED.value return dict_ @@ -670,7 +730,7 @@ def _call_detector( mapping: Optional[dict[str, str]], conv_mask_to_poly: bool, *, - db_job: Optional[Job] = None + db_job: Optional[Job] = None, ): class Results: def __init__(self, task_id, job_id: Optional[int] = None): @@ -711,15 +771,16 @@ def parse_anno(anno, labels): # Invalid label provided return None - attrs = [{ - 'spec_id': label['attributes'][attr['name']], - 'value': attr['value'] - } for attr in anno.get('attributes', []) if attr['name'] in label['attributes']] + attrs = [ + {"spec_id": label["attributes"][attr["name"]], "value": attr["value"]} + for attr in anno.get("attributes", []) + if attr["name"] in label["attributes"] + ] if anno["type"].lower() == "tag": return { "frame": frame, - "label_id": label['id'], + "label_id": label["id"], "source": "auto", "attributes": attrs, "group": None, @@ -727,14 +788,16 @@ def parse_anno(anno, labels): else: shape = { "frame": frame, - "label_id": label['id'], + "label_id": label["id"], "source": "auto", "attributes": attrs, "group": anno["group_id"] if "group_id" in anno else None, "type": anno["type"], "occluded": False, "outside": anno.get("outside", False), - "points": anno.get("mask", []) if anno["type"] == "mask" else anno.get("points", []), + "points": ( + anno.get("mask", []) if anno["type"] == "mask" else anno.get("points", []) + ), "z_order": 0, } @@ -752,7 +815,7 @@ def parse_anno(anno, labels): shape["points"] = rle if shape["type"] == "skeleton": - parsed_elements = [parse_anno(x, label['sublabels']) for x in anno["elements"]] + parsed_elements = [parse_anno(x, label["sublabels"]) for x in anno["elements"]] # find a center to set position of missing points center = [0, 0] @@ -764,25 +827,26 @@ def parse_anno(anno, labels): def _map(sublabel_body): try: - return next(filter( - lambda x: x['label_id'] == sublabel_body['id'], - parsed_elements) + return next( + filter( + lambda x: x["label_id"] == sublabel_body["id"], parsed_elements + ) ) except StopIteration: return { "frame": frame, - "label_id": sublabel_body['id'], + "label_id": sublabel_body["id"], "source": "auto", "attributes": [], "group": None, - "type": sublabel_body['type'], + "type": sublabel_body["type"], "occluded": False, "points": center, "outside": True, "z_order": 0, } - shape["elements"] = list(map(_map, label['sublabels'].values())) + shape["elements"] = list(map(_map, label["sublabels"].values())) if all(element["outside"] for element in shape["elements"]): return None @@ -796,10 +860,11 @@ def _map(sublabel_body): if frame in db_task.data.deleted_frames: continue - annotations = function.invoke(db_task, db_job=db_job, data={ - "frame": frame, "mapping": mapping, - "threshold": threshold - }) + annotations = function.invoke( + db_task, + db_job=db_job, + data={"frame": frame, "mapping": mapping, "threshold": threshold}, + ) progress = (frame + 1) / db_task.data.size if not cls._update_progress(progress): @@ -839,8 +904,7 @@ def _get_frame_set(cls, db_task: Task, db_job: Optional[Job]): data_start_frame = task_data.start_frame step = task_data.get_frame_step() frame_set = sorted( - (abs_id - data_start_frame) // step - for abs_id in db_job.segment.frame_set + (abs_id - data_start_frame) // step for abs_id in db_job.segment.frame_set ) else: frame_set = range(db_task.data.size) @@ -855,7 +919,7 @@ def _call_reid( threshold: float, max_distance: int, *, - db_job: Optional[Job] = None + db_job: Optional[Job] = None, ): if db_job: data = dm.task.get_job_data(db_job.id) @@ -883,10 +947,18 @@ def _call_reid( boxes1 = boxes_by_frame[frame1] if boxes0 and boxes1: - matching = function.invoke(db_task, db_job=db_job, data={ - "frame0": frame0, "frame1": frame1, - "boxes0": boxes0, "boxes1": boxes1, "threshold": threshold, - "max_distance": max_distance}) + matching = function.invoke( + db_task, + db_job=db_job, + data={ + "frame0": frame0, + "frame1": frame1, + "boxes0": boxes0, + "boxes1": boxes1, + "threshold": threshold, + "max_distance": max_distance, + }, + ) for idx0, idx1 in enumerate(matching): if idx1 >= 0: @@ -897,7 +969,6 @@ def _call_reid( if not LambdaJob._update_progress((i + 1) / len(frame_set)): break - for box in boxes_by_frame[frame_set[-1]]: if "path_id" not in box: path_id = len(paths) @@ -907,14 +978,16 @@ def _call_reid( tracks = [] for path_id in paths: box0 = paths[path_id][0] - tracks.append({ - "label_id": box0["label_id"], - "group": None, - "attributes": [], - "frame": box0["frame"], - "shapes": paths[path_id], - "source": str(SourceType.AUTO) - }) + tracks.append( + { + "label_id": box0["label_id"], + "group": None, + "attributes": [], + "frame": box0["frame"], + "shapes": paths[path_id], + "source": str(SourceType.AUTO), + } + ) for box in tracks[-1]["shapes"]: box.pop("id", None) @@ -947,8 +1020,8 @@ def _call_reid( def __call__(cls, function, task: int, cleanup: bool, **kwargs): # TODO: need logging db_job = None - if job := kwargs.get('job'): - db_job = Job.objects.select_related('segment', 'segment__task').get(pk=job) + if job := kwargs.get("job"): + db_job = Job.objects.select_related("segment", "segment__task").get(pk=job) db_task = db_job.segment.task else: db_task = Task.objects.get(pk=task) @@ -964,22 +1037,34 @@ def __call__(cls, function, task: int, cleanup: bool, **kwargs): def convert_labels(db_labels): labels = {} for label in db_labels: - labels[label.name] = {'id':label.id, 'attributes': {}, 'type': label.type} - if label.type == 'skeleton': - labels[label.name]['sublabels'] = convert_labels(label.sublabels.all()) + labels[label.name] = {"id": label.id, "attributes": {}, "type": label.type} + if label.type == "skeleton": + labels[label.name]["sublabels"] = convert_labels(label.sublabels.all()) for attr in label.attributespec_set.values(): - labels[label.name]['attributes'][attr['name']] = attr['id'] + labels[label.name]["attributes"][attr["name"]] = attr["id"] return labels labels = convert_labels(db_task.get_labels(prefetch=True)) if function.kind == FunctionKind.DETECTOR: - cls._call_detector(function, db_task, labels, - kwargs.get("threshold"), kwargs.get("mapping"), kwargs.get("conv_mask_to_poly"), - db_job=db_job) + cls._call_detector( + function, + db_task, + labels, + kwargs.get("threshold"), + kwargs.get("mapping"), + kwargs.get("conv_mask_to_poly"), + db_job=db_job, + ) elif function.kind == FunctionKind.REID: - cls._call_reid(function, db_task, - kwargs.get("threshold"), kwargs.get("max_distance"), db_job=db_job) + cls._call_reid( + function, + db_task, + kwargs.get("threshold"), + kwargs.get("max_distance"), + db_job=db_job, + ) + def return_response(success_code=status.HTTP_200_OK): def wrap_response(func): @@ -1011,23 +1096,28 @@ def func_wrapper(*args, **kwargs): return Response(data=data, status=status_code) return func_wrapper + return wrap_response -@extend_schema(tags=['lambda']) + +@extend_schema(tags=["lambda"]) @extend_schema_view( retrieve=extend_schema( - operation_id='lambda_retrieve_functions', - summary='Method returns the information about the function', + operation_id="lambda_retrieve_functions", + summary="Method returns the information about the function", responses={ - '200': OpenApiResponse(response=OpenApiTypes.OBJECT, description='Information about the function'), - }), + "200": OpenApiResponse( + response=OpenApiTypes.OBJECT, description="Information about the function" + ), + }, + ), list=extend_schema( - operation_id='lambda_list_functions', - summary='Method returns a list of functions') + operation_id="lambda_list_functions", summary="Method returns a list of functions" + ), ) class FunctionViewSet(viewsets.ViewSet): - lookup_value_regex = '[a-zA-Z0-9_.-]+' - lookup_field = 'func_id' + lookup_value_regex = "[a-zA-Z0-9_.-]+" + lookup_field = "func_id" iam_organization_field = None serializer_class = None @@ -1042,7 +1132,9 @@ def retrieve(self, request, func_id): gateway = LambdaGateway() return gateway.get(func_id).to_dict() - @extend_schema(description=textwrap.dedent("""\ + @extend_schema( + description=textwrap.dedent( + """\ Allows to execute a function for immediate computation. Intended for short-lived executions, useful for interactive calls. @@ -1050,44 +1142,51 @@ def retrieve(self, request, func_id): When executed for interactive annotation, the job id must be specified in the 'job' input field. The task id is not required in this case, but if it is specified, it must match the job task id. - """), - request=inline_serializer("OnlineFunctionCall", fields={ - "job": serializers.IntegerField(required=False), - "task": serializers.IntegerField(required=False), - }), - responses=OpenApiResponse(description="Returns function invocation results") + """ + ), + request=inline_serializer( + "OnlineFunctionCall", + fields={ + "job": serializers.IntegerField(required=False), + "task": serializers.IntegerField(required=False), + }, + ), + responses=OpenApiResponse(description="Returns function invocation results"), ) @return_response() def call(self, request, func_id): self.check_object_permissions(request, func_id) try: - job_id = request.data.get('job') + job_id = request.data.get("job") job = None if job_id is not None: job = Job.objects.get(id=job_id) task_id = job.get_task_id() else: - task_id = request.data['task'] + task_id = request.data["task"] db_task = Task.objects.get(pk=task_id) except (KeyError, ObjectDoesNotExist) as err: raise ValidationError( - '`{}` lambda function was run '.format(func_id) + - 'with wrong arguments ({})'.format(str(err)), - code=status.HTTP_400_BAD_REQUEST) + "`{}` lambda function was run ".format(func_id) + + "with wrong arguments ({})".format(str(err)), + code=status.HTTP_400_BAD_REQUEST, + ) gateway = LambdaGateway() lambda_func = gateway.get(func_id) response = lambda_func.invoke( db_task, - request.data, # TODO: better to add validation via serializer for these data + request.data, # TODO: better to add validation via serializer for these data db_job=job, is_interactive=True, - request=request + request=request, ) - handle_function_call(func_id, db_task, + handle_function_call( + func_id, + db_task, category="interactive", parameters={ param_name: param_value @@ -1099,41 +1198,44 @@ def call(self, request, func_id): return response -@extend_schema(tags=['lambda']) + +@extend_schema(tags=["lambda"]) @extend_schema_view( retrieve=extend_schema( - operation_id='lambda_retrieve_requests', - summary='Method returns the status of the request', + operation_id="lambda_retrieve_requests", + summary="Method returns the status of the request", parameters=[ - OpenApiParameter('id', location=OpenApiParameter.PATH, type=OpenApiTypes.STR, - description='Request id'), + OpenApiParameter( + "id", + location=OpenApiParameter.PATH, + type=OpenApiTypes.STR, + description="Request id", + ), ], - responses={ - '200': FunctionCallSerializer - } + responses={"200": FunctionCallSerializer}, ), list=extend_schema( - operation_id='lambda_list_requests', - summary='Method returns a list of requests', - responses={ - '200': FunctionCallSerializer(many=True) - } + operation_id="lambda_list_requests", + summary="Method returns a list of requests", + responses={"200": FunctionCallSerializer(many=True)}, ), create=extend_schema( parameters=ORGANIZATION_OPEN_API_PARAMETERS, - summary='Method calls the function', + summary="Method calls the function", request=FunctionCallRequestSerializer, - responses={ - '200': FunctionCallSerializer - } + responses={"200": FunctionCallSerializer}, ), destroy=extend_schema( - operation_id='lambda_delete_requests', - summary='Method cancels the request', + operation_id="lambda_delete_requests", + summary="Method cancels the request", parameters=[ - OpenApiParameter('id', location=OpenApiParameter.PATH, type=OpenApiTypes.STR, - description='Request id'), - ] + OpenApiParameter( + "id", + location=OpenApiParameter.PATH, + type=OpenApiTypes.STR, + description="Request id", + ), + ], ), ) class RequestViewSet(viewsets.ViewSet): @@ -1169,25 +1271,35 @@ def create(self, request): request_data = request_serializer.validated_data try: - function = request_data['function'] - threshold = request_data.get('threshold') - task = request_data['task'] - job = request_data.get('job', None) - cleanup = request_data.get('cleanup', False) - conv_mask_to_poly = request_data.get('conv_mask_to_poly', False) - mapping = request_data.get('mapping') - max_distance = request_data.get('max_distance') + function = request_data["function"] + threshold = request_data.get("threshold") + task = request_data["task"] + job = request_data.get("job", None) + cleanup = request_data.get("cleanup", False) + conv_mask_to_poly = request_data.get("conv_mask_to_poly", False) + mapping = request_data.get("mapping") + max_distance = request_data.get("max_distance") except KeyError as err: raise ValidationError( - '`{}` lambda function was run '.format(request_data.get('function', 'undefined')) + - 'with wrong arguments ({})'.format(str(err)), - code=status.HTTP_400_BAD_REQUEST) + "`{}` lambda function was run ".format(request_data.get("function", "undefined")) + + "with wrong arguments ({})".format(str(err)), + code=status.HTTP_400_BAD_REQUEST, + ) gateway = LambdaGateway() queue = LambdaQueue() lambda_func = gateway.get(function) - rq_job = queue.enqueue(lambda_func, threshold, task, - mapping, cleanup, conv_mask_to_poly, max_distance, request, job=job) + rq_job = queue.enqueue( + lambda_func, + threshold, + task, + mapping, + cleanup, + conv_mask_to_poly, + max_distance, + request, + job=job, + ) handle_function_call(function, job or task, category="batch") diff --git a/cvat/apps/log_viewer/apps.py b/cvat/apps/log_viewer/apps.py index 437c960e3929..a1806efc6462 100644 --- a/cvat/apps/log_viewer/apps.py +++ b/cvat/apps/log_viewer/apps.py @@ -6,8 +6,9 @@ class LogViewerConfig(AppConfig): - name = 'cvat.apps.log_viewer' + name = "cvat.apps.log_viewer" def ready(self) -> None: from cvat.apps.iam.permissions import load_app_permissions + load_app_permissions(self) diff --git a/cvat/apps/log_viewer/permissions.py b/cvat/apps/log_viewer/permissions.py index d25aa7fe275a..4ad996fb7e67 100644 --- a/cvat/apps/log_viewer/permissions.py +++ b/cvat/apps/log_viewer/permissions.py @@ -12,12 +12,12 @@ class LogViewerPermission(OpenPolicyAgentPermission): has_analytics_access: bool class Scopes(StrEnum): - VIEW = 'view' + VIEW = "view" @classmethod def create(cls, request, view, obj, iam_context): permissions = [] - if view.basename == 'analytics': + if view.basename == "analytics": for scope in cls.get_scopes(request, view, obj): self = cls.create_base_perm(request, view, scope, iam_context, obj) permissions.append(self) @@ -33,20 +33,22 @@ def create_base_perm(cls, request, view, scope, iam_context, obj=None, **kwargs) obj=obj, has_analytics_access=request.user.profile.has_analytics_access, **iam_context, - **kwargs + **kwargs, ) def __init__(self, has_analytics_access=False, **kwargs): super().__init__(**kwargs) - self.payload['input']['auth']['user']['has_analytics_access'] = has_analytics_access - self.url = settings.IAM_OPA_DATA_URL + '/analytics/allow' + self.payload["input"]["auth"]["user"]["has_analytics_access"] = has_analytics_access + self.url = settings.IAM_OPA_DATA_URL + "/analytics/allow" @staticmethod def get_scopes(request, view, obj): Scopes = __class__.Scopes - return [{ - 'list': Scopes.VIEW, - }[view.action]] + return [ + { + "list": Scopes.VIEW, + }[view.action] + ] def get_resource(self): return None diff --git a/cvat/apps/log_viewer/rules/tests/generators/analytics_test.gen.rego.py b/cvat/apps/log_viewer/rules/tests/generators/analytics_test.gen.rego.py index 95d566e4b93a..12d28193cd9f 100644 --- a/cvat/apps/log_viewer/rules/tests/generators/analytics_test.gen.rego.py +++ b/cvat/apps/log_viewer/rules/tests/generators/analytics_test.gen.rego.py @@ -62,9 +62,15 @@ def eval_rule(scope, context, ownership, privilege, membership, data, has_analyt ) ) rules = list(filter(lambda r: GROUPS.index(privilege) <= GROUPS.index(r["privilege"]), rules)) - rules = list(filter(lambda r: r["hasanalyticsaccess"] in ("na", str(has_analytics_access).lower()), rules)) + rules = list( + filter( + lambda r: r["hasanalyticsaccess"] in ("na", str(has_analytics_access).lower()), rules + ) + ) resource = data["resource"] - rules = list(filter(lambda r: not r["limit"] or eval(r["limit"], {"resource": resource}), rules)) + rules = list( + filter(lambda r: not r["limit"] or eval(r["limit"], {"resource": resource}), rules) + ) return bool(rules) @@ -78,13 +84,15 @@ def get_data(scope, context, ownership, privilege, membership, resource, has_ana "privilege": privilege, "has_analytics_access": has_analytics_access, }, - "organization": { - "id": random.randrange(100, 200), - "owner": {"id": random.randrange(200, 300)}, - "user": {"role": membership}, - } - if context == "organization" - else None, + "organization": ( + { + "id": random.randrange(100, 200), + "owner": {"id": random.randrange(200, 300)}, + "user": {"role": membership}, + } + if context == "organization" + else None + ), }, "resource": resource, } @@ -143,9 +151,15 @@ def gen_test_rego(name): if not is_valid(scope, context, ownership, privilege, membership, resource): continue - data = get_data(scope, context, ownership, privilege, membership, resource, has_analytics_access) - test_name = get_name(scope, context, ownership, privilege, membership, resource, has_analytics_access) - result = eval_rule(scope, context, ownership, privilege, membership, data, has_analytics_access) + data = get_data( + scope, context, ownership, privilege, membership, resource, has_analytics_access + ) + test_name = get_name( + scope, context, ownership, privilege, membership, resource, has_analytics_access + ) + result = eval_rule( + scope, context, ownership, privilege, membership, data, has_analytics_access + ) f.write( "{test_name} if {{\n {allow} with input as {data}\n}}\n\n".format( test_name=test_name, diff --git a/cvat/apps/log_viewer/urls.py b/cvat/apps/log_viewer/urls.py index 0de56682a37e..96e88e38c9bc 100644 --- a/cvat/apps/log_viewer/urls.py +++ b/cvat/apps/log_viewer/urls.py @@ -1,4 +1,3 @@ - # Copyright (C) 2018-2022 Intel Corporation # # SPDX-License-Identifier: MIT @@ -8,6 +7,6 @@ from . import views router = routers.DefaultRouter(trailing_slash=False) -router.register('analytics', views.LogViewerAccessViewSet, basename='analytics') +router.register("analytics", views.LogViewerAccessViewSet, basename="analytics") urlpatterns = router.urls diff --git a/cvat/apps/log_viewer/views.py b/cvat/apps/log_viewer/views.py index 0b20327f4658..362f2bb97ec3 100644 --- a/cvat/apps/log_viewer/views.py +++ b/cvat/apps/log_viewer/views.py @@ -19,7 +19,7 @@ def list(self, request): # All log view requests are proxied by Traefik in production mode which is not available in debug mode, # In order not to duplicate settings, let's just redirect to the default page in debug mode - @action(detail=False, url_path='dashboards') + @action(detail=False, url_path="dashboards") def redirect(self, request): if settings.DEBUG: - return HttpResponsePermanentRedirect('http://localhost:3001/dashboards') + return HttpResponsePermanentRedirect("http://localhost:3001/dashboards") diff --git a/cvat/apps/organizations/__init__.py b/cvat/apps/organizations/__init__.py index b1220197cf2a..f7c3408e3d12 100644 --- a/cvat/apps/organizations/__init__.py +++ b/cvat/apps/organizations/__init__.py @@ -1,4 +1,3 @@ # Copyright (C) 2021-2022 Intel Corporation # # SPDX-License-Identifier: MIT - diff --git a/cvat/apps/organizations/admin.py b/cvat/apps/organizations/admin.py index ca19407670f4..33e711189a1f 100644 --- a/cvat/apps/organizations/admin.py +++ b/cvat/apps/organizations/admin.py @@ -12,19 +12,19 @@ class MembershipInline(admin.TabularInline): extra = 0 radio_fields = { - 'role': admin.VERTICAL, + "role": admin.VERTICAL, } - autocomplete_fields = ('user', ) + autocomplete_fields = ("user",) + class OrganizationAdmin(admin.ModelAdmin): - search_fields = ('slug', 'name', 'owner__username') - list_display = ('id', 'slug', 'name') + search_fields = ("slug", "name", "owner__username") + list_display = ("id", "slug", "name") + + autocomplete_fields = ("owner",) - autocomplete_fields = ('owner', ) + inlines = [MembershipInline] - inlines = [ - MembershipInline - ] admin.site.register(Organization, OrganizationAdmin) diff --git a/cvat/apps/organizations/apps.py b/cvat/apps/organizations/apps.py index 518a646b3e94..ad654a0b8061 100644 --- a/cvat/apps/organizations/apps.py +++ b/cvat/apps/organizations/apps.py @@ -7,8 +7,9 @@ class OrganizationsConfig(AppConfig): - name = 'cvat.apps.organizations' + name = "cvat.apps.organizations" def ready(self) -> None: from cvat.apps.iam.permissions import load_app_permissions + load_app_permissions(self) diff --git a/cvat/apps/organizations/migrations/0001_initial.py b/cvat/apps/organizations/migrations/0001_initial.py index cc2ecb76cf5e..5d4887a15fb2 100644 --- a/cvat/apps/organizations/migrations/0001_initial.py +++ b/cvat/apps/organizations/migrations/0001_initial.py @@ -15,46 +15,103 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='Organization', + name="Organization", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('slug', models.SlugField(max_length=16, unique=True)), - ('name', models.CharField(blank=True, max_length=64)), - ('description', models.TextField(blank=True)), - ('created_date', models.DateTimeField(auto_now_add=True)), - ('updated_date', models.DateTimeField(auto_now=True)), - ('contact', models.JSONField(blank=True, default=dict)), - ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("slug", models.SlugField(max_length=16, unique=True)), + ("name", models.CharField(blank=True, max_length=64)), + ("description", models.TextField(blank=True)), + ("created_date", models.DateTimeField(auto_now_add=True)), + ("updated_date", models.DateTimeField(auto_now=True)), + ("contact", models.JSONField(blank=True, default=dict)), + ( + "owner", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'default_permissions': (), + "default_permissions": (), }, ), migrations.CreateModel( - name='Membership', + name="Membership", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('is_active', models.BooleanField(default=False)), - ('joined_date', models.DateTimeField(null=True)), - ('role', models.CharField(choices=[('worker', 'Worker'), ('supervisor', 'Supervisor'), ('maintainer', 'Maintainer'), ('owner', 'Owner')], max_length=16)), - ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='members', to='organizations.organization')), - ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to=settings.AUTH_USER_MODEL)), + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("is_active", models.BooleanField(default=False)), + ("joined_date", models.DateTimeField(null=True)), + ( + "role", + models.CharField( + choices=[ + ("worker", "Worker"), + ("supervisor", "Supervisor"), + ("maintainer", "Maintainer"), + ("owner", "Owner"), + ], + max_length=16, + ), + ), + ( + "organization", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="members", + to="organizations.organization", + ), + ), + ( + "user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="memberships", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'default_permissions': (), - 'unique_together': {('user', 'organization')}, + "default_permissions": (), + "unique_together": {("user", "organization")}, }, ), migrations.CreateModel( - name='Invitation', + name="Invitation", fields=[ - ('key', models.CharField(max_length=64, primary_key=True, serialize=False)), - ('created_date', models.DateTimeField(auto_now_add=True)), - ('membership', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='organizations.membership')), - ('owner', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ("key", models.CharField(max_length=64, primary_key=True, serialize=False)), + ("created_date", models.DateTimeField(auto_now_add=True)), + ( + "membership", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, to="organizations.membership" + ), + ), + ( + "owner", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'default_permissions': (), + "default_permissions": (), }, ), ] diff --git a/cvat/apps/organizations/models.py b/cvat/apps/organizations/models.py index 530f79b1ada3..d582459866f8 100644 --- a/cvat/apps/organizations/models.py +++ b/cvat/apps/organizations/models.py @@ -24,36 +24,42 @@ class Organization(TimestampedModel): description = models.TextField(blank=True) contact = models.JSONField(blank=True, default=dict) - owner = models.ForeignKey(get_user_model(), null=True, - blank=True, on_delete=models.SET_NULL, related_name='+') + owner = models.ForeignKey( + get_user_model(), null=True, blank=True, on_delete=models.SET_NULL, related_name="+" + ) def __str__(self): return self.slug + class Meta: default_permissions = () + class Membership(models.Model): - WORKER = 'worker' - SUPERVISOR = 'supervisor' - MAINTAINER = 'maintainer' - OWNER = 'owner' - - user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, - null=True, related_name='memberships') - organization = models.ForeignKey(Organization, on_delete=models.CASCADE, - related_name='members') + WORKER = "worker" + SUPERVISOR = "supervisor" + MAINTAINER = "maintainer" + OWNER = "owner" + + user = models.ForeignKey( + get_user_model(), on_delete=models.CASCADE, null=True, related_name="memberships" + ) + organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="members") is_active = models.BooleanField(default=False) joined_date = models.DateTimeField(null=True) - role = models.CharField(max_length=16, choices=[ - (WORKER, 'Worker'), - (SUPERVISOR, 'Supervisor'), - (MAINTAINER, 'Maintainer'), - (OWNER, 'Owner'), - ]) + role = models.CharField( + max_length=16, + choices=[ + (WORKER, "Worker"), + (SUPERVISOR, "Supervisor"), + (MAINTAINER, "Maintainer"), + (OWNER, "Owner"), + ], + ) class Meta: default_permissions = () - unique_together = ('user', 'organization') + unique_together = ("user", "organization") # Inspried by https://github.com/bee-keeper/django-invitations @@ -95,16 +101,16 @@ def send(self, request): site_name = current_site.name domain = current_site.domain context = { - 'email': target_email, - 'invitation_key': self.key, - 'domain': domain, - 'site_name': site_name, - 'invitation_owner': self.owner.get_username(), - 'organization_name': self.membership.organization.slug, - 'protocol': 'https' if request.is_secure() else 'http', + "email": target_email, + "invitation_key": self.key, + "domain": domain, + "site_name": site_name, + "invitation_owner": self.owner.get_username(), + "organization_name": self.membership.organization.slug, + "protocol": "https" if request.is_secure() else "http", } - get_adapter(request).send_mail('invitation/invitation', target_email, context) + get_adapter(request).send_mail("invitation/invitation", target_email, context) self.sent_date = timezone.now() self.save() diff --git a/cvat/apps/organizations/permissions.py b/cvat/apps/organizations/permissions.py index 8eaa9f074f63..1e18cf5e20c5 100644 --- a/cvat/apps/organizations/permissions.py +++ b/cvat/apps/organizations/permissions.py @@ -12,16 +12,16 @@ class OrganizationPermission(OpenPolicyAgentPermission): class Scopes(StrEnum): - LIST = 'list' - CREATE = 'create' - DELETE = 'delete' - UPDATE = 'update' - VIEW = 'view' + LIST = "list" + CREATE = "create" + DELETE = "delete" + UPDATE = "update" + VIEW = "view" @classmethod def create(cls, request, view, obj, iam_context): permissions = [] - if view.basename == 'organization': + if view.basename == "organization": for scope in cls.get_scopes(request, view, obj): self = cls.create_base_perm(request, view, scope, iam_context, obj) permissions.append(self) @@ -30,127 +30,116 @@ def create(cls, request, view, obj, iam_context): def __init__(self, **kwargs): super().__init__(**kwargs) - self.url = settings.IAM_OPA_DATA_URL + '/organizations/allow' + self.url = settings.IAM_OPA_DATA_URL + "/organizations/allow" @staticmethod def get_scopes(request, view, obj): Scopes = __class__.Scopes - return [{ - 'list': Scopes.LIST, - 'create': Scopes.CREATE, - 'destroy': Scopes.DELETE, - 'partial_update': Scopes.UPDATE, - 'retrieve': Scopes.VIEW, - }[view.action]] + return [ + { + "list": Scopes.LIST, + "create": Scopes.CREATE, + "destroy": Scopes.DELETE, + "partial_update": Scopes.UPDATE, + "retrieve": Scopes.VIEW, + }[view.action] + ] def get_resource(self): if self.obj: - membership = Membership.objects.filter( - organization=self.obj, user=self.user_id).first() + membership = Membership.objects.filter(organization=self.obj, user=self.user_id).first() return { - 'id': self.obj.id, - 'owner': { - 'id': getattr(self.obj.owner, 'id', None) - }, - 'user': { - 'role': membership.role if membership else None - } + "id": self.obj.id, + "owner": {"id": getattr(self.obj.owner, "id", None)}, + "user": {"role": membership.role if membership else None}, } elif self.scope.startswith(__class__.Scopes.CREATE.value): - return { - 'id': None, - 'owner': { - 'id': self.user_id - }, - 'user': { - 'role': 'owner' - } - } + return {"id": None, "owner": {"id": self.user_id}, "user": {"role": "owner"}} else: return None + class InvitationPermission(OpenPolicyAgentPermission): class Scopes(StrEnum): - LIST = 'list' - CREATE = 'create' - DELETE = 'delete' - ACCEPT = 'accept' - DECLINE = 'decline' - RESEND = 'resend' - VIEW = 'view' + LIST = "list" + CREATE = "create" + DELETE = "delete" + ACCEPT = "accept" + DECLINE = "decline" + RESEND = "resend" + VIEW = "view" @classmethod def create(cls, request, view, obj, iam_context): permissions = [] - if view.basename == 'invitation': + if view.basename == "invitation": for scope in cls.get_scopes(request, view, obj): - self = cls.create_base_perm(request, view, scope, iam_context, obj, - role=request.data.get('role')) + self = cls.create_base_perm( + request, view, scope, iam_context, obj, role=request.data.get("role") + ) permissions.append(self) return permissions def __init__(self, **kwargs): super().__init__(**kwargs) - self.role = kwargs.get('role') - self.url = settings.IAM_OPA_DATA_URL + '/invitations/allow' + self.role = kwargs.get("role") + self.url = settings.IAM_OPA_DATA_URL + "/invitations/allow" @staticmethod def get_scopes(request, view, obj): Scopes = __class__.Scopes - return [{ - 'list': Scopes.LIST, - 'create': Scopes.CREATE, - 'destroy': Scopes.DELETE, - 'partial_update': Scopes.ACCEPT if 'accepted' in - request.query_params else Scopes.RESEND, - 'retrieve': Scopes.VIEW, - 'accept': Scopes.ACCEPT, - 'decline': Scopes.DECLINE, - 'resend': Scopes.RESEND, - }[view.action]] + return [ + { + "list": Scopes.LIST, + "create": Scopes.CREATE, + "destroy": Scopes.DELETE, + "partial_update": ( + Scopes.ACCEPT if "accepted" in request.query_params else Scopes.RESEND + ), + "retrieve": Scopes.VIEW, + "accept": Scopes.ACCEPT, + "decline": Scopes.DECLINE, + "resend": Scopes.RESEND, + }[view.action] + ] def get_resource(self): data = None if self.obj: data = { - 'owner': { 'id': getattr(self.obj.owner, 'id', None) }, - 'invitee': { 'id': getattr(self.obj.membership.user, 'id', None) }, - 'role': self.obj.membership.role, - 'organization': { - 'id': self.obj.membership.organization.id - } + "owner": {"id": getattr(self.obj.owner, "id", None)}, + "invitee": {"id": getattr(self.obj.membership.user, "id", None)}, + "role": self.obj.membership.role, + "organization": {"id": self.obj.membership.organization.id}, } elif self.scope.startswith(__class__.Scopes.CREATE.value): data = { - 'owner': { 'id': self.user_id }, - 'invitee': { - 'id': None # unknown yet - }, - 'role': self.role, - 'organization': { - 'id': self.org_id - } if self.org_id is not None else None + "owner": {"id": self.user_id}, + "invitee": {"id": None}, # unknown yet + "role": self.role, + "organization": {"id": self.org_id} if self.org_id is not None else None, } return data + class MembershipPermission(OpenPolicyAgentPermission): class Scopes(StrEnum): - LIST = 'list' - UPDATE = 'change' - UPDATE_ROLE = 'change:role' - VIEW = 'view' - DELETE = 'delete' + LIST = "list" + UPDATE = "change" + UPDATE_ROLE = "change:role" + VIEW = "view" + DELETE = "delete" @classmethod def create(cls, request, view, obj, iam_context): permissions = [] - if view.basename == 'membership': + if view.basename == "membership": for scope in cls.get_scopes(request, view, obj): params = {} - if scope == 'change:role': - params['role'] = request.data.get('role') + if scope == "change:role": + params["role"] = request.data.get("role") self = cls.create_base_perm(request, view, scope, iam_context, obj, **params) permissions.append(self) @@ -159,7 +148,7 @@ def create(cls, request, view, obj, iam_context): def __init__(self, **kwargs): super().__init__(**kwargs) - self.url = settings.IAM_OPA_DATA_URL + '/memberships/allow' + self.url = settings.IAM_OPA_DATA_URL + "/memberships/allow" @staticmethod def get_scopes(request, view, obj): @@ -167,16 +156,21 @@ def get_scopes(request, view, obj): scopes = [] scope = { - 'list': Scopes.LIST, - 'partial_update': Scopes.UPDATE, - 'retrieve': Scopes.VIEW, - 'destroy': Scopes.DELETE, + "list": Scopes.LIST, + "partial_update": Scopes.UPDATE, + "retrieve": Scopes.VIEW, + "destroy": Scopes.DELETE, }[view.action] if scope == Scopes.UPDATE: - scopes.extend(__class__.get_per_field_update_scopes(request, { - 'role': Scopes.UPDATE_ROLE, - })) + scopes.extend( + __class__.get_per_field_update_scopes( + request, + { + "role": Scopes.UPDATE_ROLE, + }, + ) + ) else: scopes.append(scope) @@ -185,10 +179,10 @@ def get_scopes(request, view, obj): def get_resource(self): if self.obj: return { - 'role': self.obj.role, - 'is_active': self.obj.is_active, - 'user': { 'id': self.obj.user.id }, - 'organization': { 'id': self.obj.organization.id } + "role": self.obj.role, + "is_active": self.obj.is_active, + "user": {"id": self.obj.user.id}, + "organization": {"id": self.obj.organization.id}, } else: return None diff --git a/cvat/apps/organizations/rules/tests/generators/invitations_test.gen.rego.py b/cvat/apps/organizations/rules/tests/generators/invitations_test.gen.rego.py index bf7edec50713..39ff446d8eac 100644 --- a/cvat/apps/organizations/rules/tests/generators/invitations_test.gen.rego.py +++ b/cvat/apps/organizations/rules/tests/generators/invitations_test.gen.rego.py @@ -109,13 +109,15 @@ def get_data(scope, context, ownership, privilege, membership, resource, same_or "scope": scope, "auth": { "user": {"id": random.randrange(0, 100), "privilege": privilege}, - "organization": { - "id": random.randrange(100, 200), - "owner": {"id": random.randrange(200, 300)}, - "user": {"role": membership}, - } - if context == "organization" - else None, + "organization": ( + { + "id": random.randrange(100, 200), + "owner": {"id": random.randrange(200, 300)}, + "user": {"role": membership}, + } + if context == "organization" + else None + ), }, "resource": resource, } diff --git a/cvat/apps/organizations/rules/tests/generators/memberships_test.gen.rego.py b/cvat/apps/organizations/rules/tests/generators/memberships_test.gen.rego.py index c74a4a7c992b..09258163b2db 100644 --- a/cvat/apps/organizations/rules/tests/generators/memberships_test.gen.rego.py +++ b/cvat/apps/organizations/rules/tests/generators/memberships_test.gen.rego.py @@ -98,14 +98,14 @@ def eval_rule(scope, context, ownership, privilege, membership, data): return False if scope != "create" and not data["resource"]["is_active"]: - is_staff = membership == "owner" or membership == 'maintainer' + is_staff = membership == "owner" or membership == "maintainer" if is_staff: - if scope != 'view': + if scope != "view": if ORG_ROLES.index(membership) >= ORG_ROLES.index(resource["role"]): return False if GROUPS.index(privilege) > GROUPS.index("user"): return False - if resource["user"]['id'] == data["auth"]["user"]['id']: + if resource["user"]["id"] == data["auth"]["user"]["id"]: return False return True return False @@ -118,13 +118,15 @@ def get_data(scope, context, ownership, privilege, membership, resource, same_or "scope": scope, "auth": { "user": {"id": random.randrange(0, 100), "privilege": privilege}, - "organization": { - "id": random.randrange(100, 200), - "owner": {"id": random.randrange(200, 300)}, - "user": {"role": membership}, - } - if context == "organization" - else None, + "organization": ( + { + "id": random.randrange(100, 200), + "owner": {"id": random.randrange(200, 300)}, + "user": {"role": membership}, + } + if context == "organization" + else None + ), }, "resource": resource, } diff --git a/cvat/apps/organizations/rules/tests/generators/organizations_test.gen.rego.py b/cvat/apps/organizations/rules/tests/generators/organizations_test.gen.rego.py index d2a8a6fb653b..35f4fad15678 100644 --- a/cvat/apps/organizations/rules/tests/generators/organizations_test.gen.rego.py +++ b/cvat/apps/organizations/rules/tests/generators/organizations_test.gen.rego.py @@ -78,13 +78,15 @@ def get_data(scope, context, ownership, privilege, membership, resource): "scope": scope, "auth": { "user": {"id": random.randrange(0, 100), "privilege": privilege}, - "organization": { - "id": random.randrange(100, 200), - "owner": {"id": random.randrange(200, 300)}, - "user": {"role": membership}, - } - if context == "organization" - else None, + "organization": ( + { + "id": random.randrange(100, 200), + "owner": {"id": random.randrange(200, 300)}, + "user": {"role": membership}, + } + if context == "organization" + else None + ), }, "resource": {**resource, "owner": {"id": random.randrange(300, 400)}} if resource else None, } diff --git a/cvat/apps/organizations/serializers.py b/cvat/apps/organizations/serializers.py index 3e6a712473f9..6fe3a7a851a4 100644 --- a/cvat/apps/organizations/serializers.py +++ b/cvat/apps/organizations/serializers.py @@ -20,18 +20,29 @@ class OrganizationReadSerializer(serializers.ModelSerializer): owner = BasicUserSerializer(allow_null=True) + class Meta: model = Organization - fields = ['id', 'slug', 'name', 'description', 'created_date', - 'updated_date', 'contact', 'owner'] + fields = [ + "id", + "slug", + "name", + "description", + "created_date", + "updated_date", + "contact", + "owner", + ] read_only_fields = fields + class BasicOrganizationSerializer(serializers.ModelSerializer): class Meta: model = Organization - fields = ['id', 'slug'] + fields = ["id", "slug"] read_only_fields = fields + class OrganizationWriteSerializer(serializers.ModelSerializer): def to_representation(self, instance): serializer = OrganizationReadSerializer(instance, context=self.context) @@ -39,12 +50,12 @@ def to_representation(self, instance): class Meta: model = Organization - fields = ['slug', 'name', 'description', 'contact', 'owner'] + fields = ["slug", "name", "description", "contact", "owner"] # TODO: at the moment isn't possible to change the owner. It should # be a separate feature. Need to change it together with corresponding # Membership. Also such operation should be well protected. - read_only_fields = ['owner'] + read_only_fields = ["owner"] def create(self, validated_data): organization = super().create(validated_data) @@ -53,36 +64,47 @@ def create(self, validated_data): organization=organization, is_active=True, joined_date=organization.created_date, - role=Membership.OWNER) + role=Membership.OWNER, + ) return organization + class InvitationReadSerializer(serializers.ModelSerializer): - role = serializers.ChoiceField(Membership.role.field.choices, - source='membership.role') - user = BasicUserSerializer(source='membership.user') + role = serializers.ChoiceField(Membership.role.field.choices, source="membership.role") + user = BasicUserSerializer(source="membership.user") organization = serializers.PrimaryKeyRelatedField( - queryset=Organization.objects.all(), - source='membership.organization') - organization_info = BasicOrganizationSerializer(source='membership.organization') + queryset=Organization.objects.all(), source="membership.organization" + ) + organization_info = BasicOrganizationSerializer(source="membership.organization") owner = BasicUserSerializer(allow_null=True) class Meta: model = Invitation - fields = ['key', 'created_date', 'owner', 'role', 'user', 'organization', 'expired', 'organization_info'] + fields = [ + "key", + "created_date", + "owner", + "role", + "user", + "organization", + "expired", + "organization_info", + ] read_only_fields = fields extra_kwargs = { - 'expired': { - 'allow_null': True, + "expired": { + "allow_null": True, } } + class InvitationWriteSerializer(serializers.ModelSerializer): - role = serializers.ChoiceField(Membership.role.field.choices, - source='membership.role') - email = serializers.EmailField(source='membership.user.email') + role = serializers.ChoiceField(Membership.role.field.choices, source="membership.role") + email = serializers.EmailField(source="membership.user.email") organization = serializers.PrimaryKeyRelatedField( - source='membership.organization', read_only=True) + source="membership.organization", read_only=True + ) def to_representation(self, instance): serializer = InvitationReadSerializer(instance, context=self.context) @@ -90,34 +112,35 @@ def to_representation(self, instance): class Meta: model = Invitation - fields = ['key', 'created_date', 'owner', 'role', 'organization', 'email'] - read_only_fields = ['key', 'created_date', 'owner', 'organization'] + fields = ["key", "created_date", "owner", "role", "organization", "email"] + read_only_fields = ["key", "created_date", "owner", "organization"] @transaction.atomic def create(self, validated_data): - membership_data = validated_data.pop('membership') - organization = validated_data.pop('organization') + membership_data = validated_data.pop("membership") + organization = validated_data.pop("organization") try: - user = get_user_model().objects.get( - email__iexact=membership_data['user']['email']) - del membership_data['user'] + user = get_user_model().objects.get(email__iexact=membership_data["user"]["email"]) + del membership_data["user"] except ObjectDoesNotExist: - user_email = membership_data['user']['email'] + user_email = membership_data["user"]["email"] user = User.objects.create_user(username=user_email, email=user_email) user.set_unusable_password() # User.objects.create_user(...) normalizes passed email and user.email can be different from original user_email - email = EmailAddress.objects.create(user=user, email=user.email, primary=True, verified=False) + email = EmailAddress.objects.create( + user=user, email=user.email, primary=True, verified=False + ) user.save() email.save() - del membership_data['user'] + del membership_data["user"] membership, created = Membership.objects.get_or_create( - defaults=membership_data, - user=user, organization=organization) + defaults=membership_data, user=user, organization=organization + ) if not created: - raise serializers.ValidationError('The user is a member of ' - 'the organization already.') - invitation = Invitation.objects.create(**validated_data, - membership=membership) + raise serializers.ValidationError( + "The user is a member of " "the organization already." + ) + invitation = Invitation.objects.create(**validated_data, membership=membership) return invitation @@ -134,20 +157,21 @@ def save(self, request, **kwargs): return invitation + class MembershipReadSerializer(serializers.ModelSerializer): user = BasicUserSerializer() class Meta: model = Membership - fields = ['id', 'user', 'organization', 'is_active', 'joined_date', 'role', - 'invitation'] + fields = ["id", "user", "organization", "is_active", "joined_date", "role", "invitation"] read_only_fields = fields extra_kwargs = { - 'invitation': { - 'allow_null': True, # owner of an organization does not have an invitation + "invitation": { + "allow_null": True, # owner of an organization does not have an invitation } } + class MembershipWriteSerializer(serializers.ModelSerializer): def to_representation(self, instance): serializer = MembershipReadSerializer(instance, context=self.context) @@ -155,8 +179,9 @@ def to_representation(self, instance): class Meta: model = Membership - fields = ['id', 'user', 'organization', 'is_active', 'joined_date', 'role'] - read_only_fields = ['user', 'organization', 'is_active', 'joined_date'] + fields = ["id", "user", "organization", "is_active", "joined_date", "role"] + read_only_fields = ["user", "organization", "is_active", "joined_date"] + class AcceptInvitationReadSerializer(serializers.Serializer): organization_slug = serializers.CharField() diff --git a/cvat/apps/organizations/throttle.py b/cvat/apps/organizations/throttle.py index c99db364030c..342b9463170b 100644 --- a/cvat/apps/organizations/throttle.py +++ b/cvat/apps/organizations/throttle.py @@ -6,4 +6,4 @@ class ResendOrganizationInvitationThrottle(UserRateThrottle): - rate = '5/hour' + rate = "5/hour" diff --git a/cvat/apps/organizations/urls.py b/cvat/apps/organizations/urls.py index 1e68d0299cc3..4ec7fdc628bc 100644 --- a/cvat/apps/organizations/urls.py +++ b/cvat/apps/organizations/urls.py @@ -7,8 +7,8 @@ from .views import InvitationViewSet, MembershipViewSet, OrganizationViewSet router = DefaultRouter(trailing_slash=False) -router.register('organizations', OrganizationViewSet) -router.register('invitations', InvitationViewSet) -router.register('memberships', MembershipViewSet) +router.register("organizations", OrganizationViewSet) +router.register("invitations", InvitationViewSet) +router.register("memberships", MembershipViewSet) urlpatterns = router.urls diff --git a/cvat/apps/organizations/views.py b/cvat/apps/organizations/views.py index f628bff3e1d9..dbb1eeec9a9c 100644 --- a/cvat/apps/organizations/views.py +++ b/cvat/apps/organizations/views.py @@ -33,51 +33,57 @@ ) -@extend_schema(tags=['organizations']) +@extend_schema(tags=["organizations"]) @extend_schema_view( retrieve=extend_schema( - summary='Get organization details', + summary="Get organization details", responses={ - '200': OrganizationReadSerializer, - }), + "200": OrganizationReadSerializer, + }, + ), list=extend_schema( - summary='List organizations', + summary="List organizations", responses={ - '200': OrganizationReadSerializer(many=True), - }), + "200": OrganizationReadSerializer(many=True), + }, + ), partial_update=extend_schema( - summary='Update an organization', + summary="Update an organization", request=OrganizationWriteSerializer(partial=True), responses={ - '200': OrganizationReadSerializer, # check OrganizationWriteSerializer.to_representation - }), + "200": OrganizationReadSerializer, # check OrganizationWriteSerializer.to_representation + }, + ), create=extend_schema( - summary='Create an organization', + summary="Create an organization", request=OrganizationWriteSerializer, responses={ - '201': OrganizationReadSerializer, # check OrganizationWriteSerializer.to_representation - }), + "201": OrganizationReadSerializer, # check OrganizationWriteSerializer.to_representation + }, + ), destroy=extend_schema( - summary='Delete an organization', + summary="Delete an organization", responses={ - '204': OpenApiResponse(description='The organization has been deleted'), - }) + "204": OpenApiResponse(description="The organization has been deleted"), + }, + ), ) -class OrganizationViewSet(viewsets.GenericViewSet, - mixins.RetrieveModelMixin, - mixins.ListModelMixin, - mixins.CreateModelMixin, - mixins.DestroyModelMixin, - PartialUpdateModelMixin, - ): - queryset = Organization.objects.select_related('owner').all() - search_fields = ('name', 'owner', 'slug') - filter_fields = list(search_fields) + ['id'] +class OrganizationViewSet( + viewsets.GenericViewSet, + mixins.RetrieveModelMixin, + mixins.ListModelMixin, + mixins.CreateModelMixin, + mixins.DestroyModelMixin, + PartialUpdateModelMixin, +): + queryset = Organization.objects.select_related("owner").all() + search_fields = ("name", "owner", "slug") + filter_fields = list(search_fields) + ["id"] simple_filters = list(search_fields) - lookup_fields = {'owner': 'owner__username'} + lookup_fields = {"owner": "owner__username"} ordering_fields = list(filter_fields) - ordering = '-id' - http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options'] + ordering = "-id" + http_method_names = ["get", "post", "patch", "delete", "head", "options"] iam_organization_field = None def get_queryset(self): @@ -93,50 +99,60 @@ def get_serializer_class(self): return OrganizationWriteSerializer def perform_create(self, serializer): - extra_kwargs = { 'owner': self.request.user } - if not serializer.validated_data.get('name'): - extra_kwargs.update({ 'name': serializer.validated_data['slug'] }) + extra_kwargs = {"owner": self.request.user} + if not serializer.validated_data.get("name"): + extra_kwargs.update({"name": serializer.validated_data["slug"]}) serializer.save(**extra_kwargs) class Meta: model = Membership - fields = ("user", ) + fields = ("user",) -@extend_schema(tags=['memberships']) + +@extend_schema(tags=["memberships"]) @extend_schema_view( retrieve=extend_schema( - summary='Get membership details', + summary="Get membership details", responses={ - '200': MembershipReadSerializer, - }), + "200": MembershipReadSerializer, + }, + ), list=extend_schema( - summary='List memberships', + summary="List memberships", responses={ - '200': MembershipReadSerializer(many=True), - }), + "200": MembershipReadSerializer(many=True), + }, + ), partial_update=extend_schema( - summary='Update a membership', + summary="Update a membership", request=MembershipWriteSerializer(partial=True), responses={ - '200': MembershipReadSerializer, # check MembershipWriteSerializer.to_representation - }), + "200": MembershipReadSerializer, # check MembershipWriteSerializer.to_representation + }, + ), destroy=extend_schema( - summary='Delete a membership', + summary="Delete a membership", responses={ - '204': OpenApiResponse(description='The membership has been deleted'), - }) + "204": OpenApiResponse(description="The membership has been deleted"), + }, + ), ) -class MembershipViewSet(mixins.RetrieveModelMixin, mixins.DestroyModelMixin, - mixins.ListModelMixin, PartialUpdateModelMixin, viewsets.GenericViewSet): - queryset = Membership.objects.select_related('invitation', 'user').all() - ordering = '-id' - http_method_names = ['get', 'patch', 'delete', 'head', 'options'] - search_fields = ('user', 'role') - filter_fields = list(search_fields) + ['id'] +class MembershipViewSet( + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + PartialUpdateModelMixin, + viewsets.GenericViewSet, +): + queryset = Membership.objects.select_related("invitation", "user").all() + ordering = "-id" + http_method_names = ["get", "patch", "delete", "head", "options"] + search_fields = ("user", "role") + filter_fields = list(search_fields) + ["id"] simple_filters = list(search_fields) ordering_fields = list(filter_fields) - lookup_fields = {'user': 'user__username'} - iam_organization_field = 'organization' + lookup_fields = {"user": "user__username"} + iam_organization_field = "organization" def get_serializer_class(self): if self.request.method in SAFE_METHODS: @@ -147,86 +163,98 @@ def get_serializer_class(self): def get_queryset(self): queryset = super().get_queryset() - if self.action == 'list': + if self.action == "list": permission = MembershipPermission.create_scope_list(self.request) queryset = permission.filter(queryset) return queryset -@extend_schema(tags=['invitations']) + +@extend_schema(tags=["invitations"]) @extend_schema_view( retrieve=extend_schema( - summary='Get invitation details', + summary="Get invitation details", responses={ - '200': InvitationReadSerializer, - }), + "200": InvitationReadSerializer, + }, + ), list=extend_schema( - summary='List invitations', + summary="List invitations", responses={ - '200': InvitationReadSerializer(many=True), - }), + "200": InvitationReadSerializer(many=True), + }, + ), partial_update=extend_schema( - summary='Update an invitation', + summary="Update an invitation", request=InvitationWriteSerializer(partial=True), responses={ - '200': InvitationReadSerializer, # check InvitationWriteSerializer.to_representation - }), + "200": InvitationReadSerializer, # check InvitationWriteSerializer.to_representation + }, + ), create=extend_schema( - summary='Create an invitation', + summary="Create an invitation", request=InvitationWriteSerializer, parameters=ORGANIZATION_OPEN_API_PARAMETERS, responses={ - '201': InvitationReadSerializer, # check InvitationWriteSerializer.to_representation - }), + "201": InvitationReadSerializer, # check InvitationWriteSerializer.to_representation + }, + ), destroy=extend_schema( - summary='Delete an invitation', + summary="Delete an invitation", responses={ - '204': OpenApiResponse(description='The invitation has been deleted'), - }), + "204": OpenApiResponse(description="The invitation has been deleted"), + }, + ), accept=extend_schema( - operation_id='invitations_accept', + operation_id="invitations_accept", request=None, - summary='Accept an invitation', + summary="Accept an invitation", responses={ - '200': OpenApiResponse(response=AcceptInvitationReadSerializer, description='The invitation is accepted'), - '400': OpenApiResponse(description='The invitation is expired or already accepted'), - }), + "200": OpenApiResponse( + response=AcceptInvitationReadSerializer, description="The invitation is accepted" + ), + "400": OpenApiResponse(description="The invitation is expired or already accepted"), + }, + ), decline=extend_schema( - operation_id='invitations_decline', + operation_id="invitations_decline", request=None, - summary='Decline an invitation', + summary="Decline an invitation", responses={ - '204': OpenApiResponse(description='The invitation has been declined'), - }), + "204": OpenApiResponse(description="The invitation has been declined"), + }, + ), resend=extend_schema( - operation_id='invitations_resend', - summary='Resend an invitation', + operation_id="invitations_resend", + summary="Resend an invitation", request=None, responses={ - '204': OpenApiResponse(description='Invitation has been sent'), - '400': OpenApiResponse(description='The invitation is already accepted'), - }), + "204": OpenApiResponse(description="Invitation has been sent"), + "400": OpenApiResponse(description="The invitation is already accepted"), + }, + ), ) -class InvitationViewSet(viewsets.GenericViewSet, - mixins.RetrieveModelMixin, - mixins.ListModelMixin, - PartialUpdateModelMixin, - mixins.CreateModelMixin, - mixins.DestroyModelMixin, - ): +class InvitationViewSet( + viewsets.GenericViewSet, + mixins.RetrieveModelMixin, + mixins.ListModelMixin, + PartialUpdateModelMixin, + mixins.CreateModelMixin, + mixins.DestroyModelMixin, +): queryset = Invitation.objects.all() - http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options'] - iam_organization_field = 'membership__organization' + http_method_names = ["get", "post", "patch", "delete", "head", "options"] + iam_organization_field = "membership__organization" - search_fields = ('owner',) - filter_fields = list(search_fields) + ['user_id', 'accepted'] + search_fields = ("owner",) + filter_fields = list(search_fields) + ["user_id", "accepted"] simple_filters = list(search_fields) - ordering_fields = list(simple_filters) + ['created_date'] - ordering = '-created_date' + ordering_fields = list(simple_filters) + ["created_date"] + ordering = "-created_date" lookup_fields = { - 'owner': 'owner__username', - 'user_id': 'membership__user__id', - 'accepted': 'membership__is_active', + "owner": "owner__username", + "user_id": "membership__user__id", + "accepted": "membership__is_active", } def get_serializer_class(self): @@ -247,7 +275,10 @@ def create(self, request): try: self.perform_create(serializer) except ImproperlyConfigured: - return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR, data="Email backend is not configured.") + return Response( + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + data="Email backend is not configured.", + ) return Response(serializer.data, status=status.HTTP_201_CREATED) @@ -255,51 +286,75 @@ def perform_create(self, serializer): serializer.save( owner=self.request.user, key=get_random_string(length=64), - organization=self.request.iam_context['organization'], + organization=self.request.iam_context["organization"], request=self.request, ) def perform_update(self, serializer): - if 'accepted' in self.request.query_params: + if "accepted" in self.request.query_params: serializer.instance.accept() else: super().perform_update(serializer) @transaction.atomic - @action(detail=True, methods=['POST'], url_path='accept') + @action(detail=True, methods=["POST"], url_path="accept") def accept(self, request, pk): try: - invitation = self.get_object() # force to call check_object_permissions + invitation = self.get_object() # force to call check_object_permissions if invitation.expired: - return Response(status=status.HTTP_400_BAD_REQUEST, data="Your invitation is expired. Please contact organization owner to renew it.") + return Response( + status=status.HTTP_400_BAD_REQUEST, + data="Your invitation is expired. Please contact organization owner to renew it.", + ) if invitation.membership.is_active: - return Response(status=status.HTTP_400_BAD_REQUEST, data="Your invitation is already accepted.") + return Response( + status=status.HTTP_400_BAD_REQUEST, data="Your invitation is already accepted." + ) invitation.accept() - response_serializer = AcceptInvitationReadSerializer(data={'organization_slug': invitation.membership.organization.slug}) + response_serializer = AcceptInvitationReadSerializer( + data={"organization_slug": invitation.membership.organization.slug} + ) response_serializer.is_valid(raise_exception=True) return Response(status=status.HTTP_200_OK, data=response_serializer.data) except Invitation.DoesNotExist: - return Response(status=status.HTTP_404_NOT_FOUND, data="This invitation does not exist. Please contact organization owner.") + return Response( + status=status.HTTP_404_NOT_FOUND, + data="This invitation does not exist. Please contact organization owner.", + ) - @action(detail=True, methods=['POST'], url_path='resend', throttle_classes=[ResendOrganizationInvitationThrottle]) + @action( + detail=True, + methods=["POST"], + url_path="resend", + throttle_classes=[ResendOrganizationInvitationThrottle], + ) def resend(self, request, pk): try: - invitation = self.get_object() # force to call check_object_permissions + invitation = self.get_object() # force to call check_object_permissions if invitation.membership.is_active: - return Response(status=status.HTTP_400_BAD_REQUEST, data="This invitation is already accepted.") + return Response( + status=status.HTTP_400_BAD_REQUEST, data="This invitation is already accepted." + ) invitation.send(request) return Response(status=status.HTTP_204_NO_CONTENT) except Invitation.DoesNotExist: - return Response(status=status.HTTP_404_NOT_FOUND, data="This invitation does not exist.") + return Response( + status=status.HTTP_404_NOT_FOUND, data="This invitation does not exist." + ) except ImproperlyConfigured: - return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR, data="Email backend is not configured.") + return Response( + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + data="Email backend is not configured.", + ) - @action(detail=True, methods=['POST'], url_path='decline') + @action(detail=True, methods=["POST"], url_path="decline") def decline(self, request, pk): try: - invitation = self.get_object() # force to call check_object_permissions + invitation = self.get_object() # force to call check_object_permissions membership = invitation.membership membership.delete() return Response(status=status.HTTP_204_NO_CONTENT) except Invitation.DoesNotExist: - return Response(status=status.HTTP_404_NOT_FOUND, data="This invitation does not exist.") + return Response( + status=status.HTTP_404_NOT_FOUND, data="This invitation does not exist." + ) diff --git a/cvat/apps/webhooks/apps.py b/cvat/apps/webhooks/apps.py index 50ba88aa6278..0b4cf34198f7 100644 --- a/cvat/apps/webhooks/apps.py +++ b/cvat/apps/webhooks/apps.py @@ -10,6 +10,7 @@ class WebhooksConfig(AppConfig): def ready(self): from cvat.apps.iam.permissions import load_app_permissions + load_app_permissions(self) from . import signals # pylint: disable=unused-import diff --git a/cvat/apps/webhooks/event_type.py b/cvat/apps/webhooks/event_type.py index 59cdb6cf99ed..ef98e5212824 100644 --- a/cvat/apps/webhooks/event_type.py +++ b/cvat/apps/webhooks/event_type.py @@ -47,7 +47,11 @@ class AllEvents: class ProjectEvents: webhook_type = WebhookTypeChoice.PROJECT - events = [*Events.select(["task", "job", "label", "issue", "comment"]), event_name("update", "project"), event_name("delete", "project")] + events = [ + *Events.select(["task", "job", "label", "issue", "comment"]), + event_name("update", "project"), + event_name("delete", "project"), + ] class OrganizationEvents: diff --git a/cvat/apps/webhooks/migrations/0001_initial.py b/cvat/apps/webhooks/migrations/0001_initial.py index 49a8af2f582c..e3638bd6be97 100644 --- a/cvat/apps/webhooks/migrations/0001_initial.py +++ b/cvat/apps/webhooks/migrations/0001_initial.py @@ -12,54 +12,120 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('engine', '0060_alter_label_parent'), + ("engine", "0060_alter_label_parent"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('organizations', '0001_initial'), + ("organizations", "0001_initial"), ] operations = [ migrations.CreateModel( - name='Webhook', + name="Webhook", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('target_url', models.URLField()), - ('description', models.CharField(blank=True, default='', max_length=128)), - ('events', models.CharField(default='', max_length=4096)), - ('type', models.CharField(choices=[('organization', 'ORGANIZATION'), ('project', 'PROJECT')], max_length=16)), - ('content_type', models.CharField(choices=[('application/json', 'JSON')], default=cvat.apps.webhooks.models.WebhookContentTypeChoice['JSON'], max_length=64)), - ('secret', models.CharField(blank=True, default='', max_length=64)), - ('is_active', models.BooleanField(default=True)), - ('enable_ssl', models.BooleanField(default=True)), - ('created_date', models.DateTimeField(auto_now_add=True)), - ('updated_date', models.DateTimeField(auto_now=True)), - ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='organizations.organization')), - ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('project', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='engine.project')), + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("target_url", models.URLField()), + ("description", models.CharField(blank=True, default="", max_length=128)), + ("events", models.CharField(default="", max_length=4096)), + ( + "type", + models.CharField( + choices=[("organization", "ORGANIZATION"), ("project", "PROJECT")], + max_length=16, + ), + ), + ( + "content_type", + models.CharField( + choices=[("application/json", "JSON")], + default=cvat.apps.webhooks.models.WebhookContentTypeChoice["JSON"], + max_length=64, + ), + ), + ("secret", models.CharField(blank=True, default="", max_length=64)), + ("is_active", models.BooleanField(default=True)), + ("enable_ssl", models.BooleanField(default=True)), + ("created_date", models.DateTimeField(auto_now_add=True)), + ("updated_date", models.DateTimeField(auto_now=True)), + ( + "organization", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="organizations.organization", + ), + ), + ( + "owner", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="engine.project", + ), + ), ], options={ - 'default_permissions': (), + "default_permissions": (), }, ), migrations.CreateModel( - name='WebhookDelivery', + name="WebhookDelivery", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('event', models.CharField(max_length=64)), - ('status_code', models.CharField(max_length=128, null=True)), - ('redelivery', models.BooleanField(default=False)), - ('created_date', models.DateTimeField(auto_now_add=True)), - ('updated_date', models.DateTimeField(auto_now=True)), - ('changed_fields', models.CharField(default='', max_length=4096)), - ('request', models.JSONField(default=dict)), - ('response', models.JSONField(default=dict)), - ('webhook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deliveries', to='webhooks.webhook')), + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("event", models.CharField(max_length=64)), + ("status_code", models.CharField(max_length=128, null=True)), + ("redelivery", models.BooleanField(default=False)), + ("created_date", models.DateTimeField(auto_now_add=True)), + ("updated_date", models.DateTimeField(auto_now=True)), + ("changed_fields", models.CharField(default="", max_length=4096)), + ("request", models.JSONField(default=dict)), + ("response", models.JSONField(default=dict)), + ( + "webhook", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="deliveries", + to="webhooks.webhook", + ), + ), ], options={ - 'default_permissions': (), + "default_permissions": (), }, ), migrations.AddConstraint( - model_name='webhook', - constraint=models.CheckConstraint(check=models.Q(models.Q(('project_id__isnull', False), ('type', 'project')), models.Q(('organization_id__isnull', False), ('project_id__isnull', True), ('type', 'organization')), _connector='OR'), name='webhooks_project_or_organization'), + model_name="webhook", + constraint=models.CheckConstraint( + check=models.Q( + models.Q(("project_id__isnull", False), ("type", "project")), + models.Q( + ("organization_id__isnull", False), + ("project_id__isnull", True), + ("type", "organization"), + ), + _connector="OR", + ), + name="webhooks_project_or_organization", + ), ), ] diff --git a/cvat/apps/webhooks/migrations/0002_alter_webhookdelivery_status_code.py b/cvat/apps/webhooks/migrations/0002_alter_webhookdelivery_status_code.py index fd1a2397d249..0429b1445117 100644 --- a/cvat/apps/webhooks/migrations/0002_alter_webhookdelivery_status_code.py +++ b/cvat/apps/webhooks/migrations/0002_alter_webhookdelivery_status_code.py @@ -6,13 +6,77 @@ class Migration(migrations.Migration): dependencies = [ - ('webhooks', '0001_initial'), + ("webhooks", "0001_initial"), ] operations = [ migrations.AlterField( - model_name='webhookdelivery', - name='status_code', - field=models.IntegerField(choices=[('CONTINUE', 100), ('SWITCHING_PROTOCOLS', 101), ('PROCESSING', 102), ('OK', 200), ('CREATED', 201), ('ACCEPTED', 202), ('NON_AUTHORITATIVE_INFORMATION', 203), ('NO_CONTENT', 204), ('RESET_CONTENT', 205), ('PARTIAL_CONTENT', 206), ('MULTI_STATUS', 207), ('ALREADY_REPORTED', 208), ('IM_USED', 226), ('MULTIPLE_CHOICES', 300), ('MOVED_PERMANENTLY', 301), ('FOUND', 302), ('SEE_OTHER', 303), ('NOT_MODIFIED', 304), ('USE_PROXY', 305), ('TEMPORARY_REDIRECT', 307), ('PERMANENT_REDIRECT', 308), ('BAD_REQUEST', 400), ('UNAUTHORIZED', 401), ('PAYMENT_REQUIRED', 402), ('FORBIDDEN', 403), ('NOT_FOUND', 404), ('METHOD_NOT_ALLOWED', 405), ('NOT_ACCEPTABLE', 406), ('PROXY_AUTHENTICATION_REQUIRED', 407), ('REQUEST_TIMEOUT', 408), ('CONFLICT', 409), ('GONE', 410), ('LENGTH_REQUIRED', 411), ('PRECONDITION_FAILED', 412), ('REQUEST_ENTITY_TOO_LARGE', 413), ('REQUEST_URI_TOO_LONG', 414), ('UNSUPPORTED_MEDIA_TYPE', 415), ('REQUESTED_RANGE_NOT_SATISFIABLE', 416), ('EXPECTATION_FAILED', 417), ('MISDIRECTED_REQUEST', 421), ('UNPROCESSABLE_ENTITY', 422), ('LOCKED', 423), ('FAILED_DEPENDENCY', 424), ('UPGRADE_REQUIRED', 426), ('PRECONDITION_REQUIRED', 428), ('TOO_MANY_REQUESTS', 429), ('REQUEST_HEADER_FIELDS_TOO_LARGE', 431), ('UNAVAILABLE_FOR_LEGAL_REASONS', 451), ('INTERNAL_SERVER_ERROR', 500), ('NOT_IMPLEMENTED', 501), ('BAD_GATEWAY', 502), ('SERVICE_UNAVAILABLE', 503), ('GATEWAY_TIMEOUT', 504), ('HTTP_VERSION_NOT_SUPPORTED', 505), ('VARIANT_ALSO_NEGOTIATES', 506), ('INSUFFICIENT_STORAGE', 507), ('LOOP_DETECTED', 508), ('NOT_EXTENDED', 510), ('NETWORK_AUTHENTICATION_REQUIRED', 511)], default=None, null=True), + model_name="webhookdelivery", + name="status_code", + field=models.IntegerField( + choices=[ + ("CONTINUE", 100), + ("SWITCHING_PROTOCOLS", 101), + ("PROCESSING", 102), + ("OK", 200), + ("CREATED", 201), + ("ACCEPTED", 202), + ("NON_AUTHORITATIVE_INFORMATION", 203), + ("NO_CONTENT", 204), + ("RESET_CONTENT", 205), + ("PARTIAL_CONTENT", 206), + ("MULTI_STATUS", 207), + ("ALREADY_REPORTED", 208), + ("IM_USED", 226), + ("MULTIPLE_CHOICES", 300), + ("MOVED_PERMANENTLY", 301), + ("FOUND", 302), + ("SEE_OTHER", 303), + ("NOT_MODIFIED", 304), + ("USE_PROXY", 305), + ("TEMPORARY_REDIRECT", 307), + ("PERMANENT_REDIRECT", 308), + ("BAD_REQUEST", 400), + ("UNAUTHORIZED", 401), + ("PAYMENT_REQUIRED", 402), + ("FORBIDDEN", 403), + ("NOT_FOUND", 404), + ("METHOD_NOT_ALLOWED", 405), + ("NOT_ACCEPTABLE", 406), + ("PROXY_AUTHENTICATION_REQUIRED", 407), + ("REQUEST_TIMEOUT", 408), + ("CONFLICT", 409), + ("GONE", 410), + ("LENGTH_REQUIRED", 411), + ("PRECONDITION_FAILED", 412), + ("REQUEST_ENTITY_TOO_LARGE", 413), + ("REQUEST_URI_TOO_LONG", 414), + ("UNSUPPORTED_MEDIA_TYPE", 415), + ("REQUESTED_RANGE_NOT_SATISFIABLE", 416), + ("EXPECTATION_FAILED", 417), + ("MISDIRECTED_REQUEST", 421), + ("UNPROCESSABLE_ENTITY", 422), + ("LOCKED", 423), + ("FAILED_DEPENDENCY", 424), + ("UPGRADE_REQUIRED", 426), + ("PRECONDITION_REQUIRED", 428), + ("TOO_MANY_REQUESTS", 429), + ("REQUEST_HEADER_FIELDS_TOO_LARGE", 431), + ("UNAVAILABLE_FOR_LEGAL_REASONS", 451), + ("INTERNAL_SERVER_ERROR", 500), + ("NOT_IMPLEMENTED", 501), + ("BAD_GATEWAY", 502), + ("SERVICE_UNAVAILABLE", 503), + ("GATEWAY_TIMEOUT", 504), + ("HTTP_VERSION_NOT_SUPPORTED", 505), + ("VARIANT_ALSO_NEGOTIATES", 506), + ("INSUFFICIENT_STORAGE", 507), + ("LOOP_DETECTED", 508), + ("NOT_EXTENDED", 510), + ("NETWORK_AUTHENTICATION_REQUIRED", 511), + ], + default=None, + null=True, + ), ), ] diff --git a/cvat/apps/webhooks/migrations/0003_alter_webhookdelivery_status_code.py b/cvat/apps/webhooks/migrations/0003_alter_webhookdelivery_status_code.py index 676f03a2dc9b..234a4d685d58 100644 --- a/cvat/apps/webhooks/migrations/0003_alter_webhookdelivery_status_code.py +++ b/cvat/apps/webhooks/migrations/0003_alter_webhookdelivery_status_code.py @@ -6,13 +6,13 @@ class Migration(migrations.Migration): dependencies = [ - ('webhooks', '0002_alter_webhookdelivery_status_code'), + ("webhooks", "0002_alter_webhookdelivery_status_code"), ] operations = [ migrations.AlterField( - model_name='webhookdelivery', - name='status_code', + model_name="webhookdelivery", + name="status_code", field=models.PositiveIntegerField(default=None, null=True), ), ] diff --git a/cvat/apps/webhooks/migrations/0004_alter_webhook_target_url.py b/cvat/apps/webhooks/migrations/0004_alter_webhook_target_url.py index 00be6a309df2..f2f716f8cd88 100644 --- a/cvat/apps/webhooks/migrations/0004_alter_webhook_target_url.py +++ b/cvat/apps/webhooks/migrations/0004_alter_webhook_target_url.py @@ -6,13 +6,13 @@ class Migration(migrations.Migration): dependencies = [ - ('webhooks', '0003_alter_webhookdelivery_status_code'), + ("webhooks", "0003_alter_webhookdelivery_status_code"), ] operations = [ migrations.AlterField( - model_name='webhook', - name='target_url', + model_name="webhook", + name="target_url", field=models.URLField(max_length=8192), ), ] diff --git a/cvat/apps/webhooks/models.py b/cvat/apps/webhooks/models.py index 104faccd60a4..650cd814fae0 100644 --- a/cvat/apps/webhooks/models.py +++ b/cvat/apps/webhooks/models.py @@ -53,9 +53,7 @@ class Webhook(TimestampedModel): owner = models.ForeignKey( User, null=True, blank=True, on_delete=models.SET_NULL, related_name="+" ) - project = models.ForeignKey( - Project, null=True, on_delete=models.CASCADE, related_name="+" - ) + project = models.ForeignKey(Project, null=True, on_delete=models.CASCADE, related_name="+") organization = models.ForeignKey( Organization, null=True, on_delete=models.CASCADE, related_name="+" ) @@ -66,9 +64,7 @@ class Meta: models.CheckConstraint( name="webhooks_project_or_organization", check=( - models.Q( - type=WebhookTypeChoice.PROJECT.value, project_id__isnull=False - ) + models.Q(type=WebhookTypeChoice.PROJECT.value, project_id__isnull=False) | models.Q( type=WebhookTypeChoice.ORGANIZATION.value, project_id__isnull=True, @@ -80,9 +76,7 @@ class Meta: class WebhookDelivery(TimestampedModel): - webhook = models.ForeignKey( - Webhook, on_delete=models.CASCADE, related_name="deliveries" - ) + webhook = models.ForeignKey(Webhook, on_delete=models.CASCADE, related_name="deliveries") event = models.CharField(max_length=64) status_code = models.PositiveIntegerField(null=True, default=None) diff --git a/cvat/apps/webhooks/permissions.py b/cvat/apps/webhooks/permissions.py index 7fe0904c7ed2..3ce72bd350a4 100644 --- a/cvat/apps/webhooks/permissions.py +++ b/cvat/apps/webhooks/permissions.py @@ -15,25 +15,26 @@ class WebhookPermission(OpenPolicyAgentPermission): class Scopes(StrEnum): - CREATE = 'create' - CREATE_IN_PROJECT = 'create@project' - CREATE_IN_ORG = 'create@organization' - DELETE = 'delete' - UPDATE = 'update' - LIST = 'list' - VIEW = 'view' + CREATE = "create" + CREATE_IN_PROJECT = "create@project" + CREATE_IN_ORG = "create@organization" + DELETE = "delete" + UPDATE = "update" + LIST = "list" + VIEW = "view" @classmethod def create(cls, request, view, obj, iam_context): permissions = [] - if view.basename == 'webhook': - project_id = request.data.get('project_id') + if view.basename == "webhook": + project_id = request.data.get("project_id") for scope in cls.get_scopes(request, view, obj): - self = cls.create_base_perm(request, view, scope, iam_context, obj, - project_id=project_id) + self = cls.create_base_perm( + request, view, scope, iam_context, obj, project_id=project_id + ) permissions.append(self) - owner = request.data.get('owner_id') or request.data.get('owner') + owner = request.data.get("owner_id") or request.data.get("owner") if owner: perm = UserPermission.create_scope_view(iam_context, owner) permissions.append(perm) @@ -46,29 +47,29 @@ def create(cls, request, view, obj, iam_context): def __init__(self, **kwargs): super().__init__(**kwargs) - self.url = settings.IAM_OPA_DATA_URL + '/webhooks/allow' + self.url = settings.IAM_OPA_DATA_URL + "/webhooks/allow" @staticmethod def get_scopes(request, view, obj): Scopes = __class__.Scopes scope = { - ('create', 'POST'): Scopes.CREATE, - ('destroy', 'DELETE'): Scopes.DELETE, - ('partial_update', 'PATCH'): Scopes.UPDATE, - ('update', 'PUT'): Scopes.UPDATE, - ('list', 'GET'): Scopes.LIST, - ('retrieve', 'GET'): Scopes.VIEW, - ('ping', 'POST'): Scopes.UPDATE, - ('deliveries', 'GET'): Scopes.VIEW, - ('retrieve_delivery', 'GET'): Scopes.VIEW, - ('redelivery', 'POST'): Scopes.UPDATE, + ("create", "POST"): Scopes.CREATE, + ("destroy", "DELETE"): Scopes.DELETE, + ("partial_update", "PATCH"): Scopes.UPDATE, + ("update", "PUT"): Scopes.UPDATE, + ("list", "GET"): Scopes.LIST, + ("retrieve", "GET"): Scopes.VIEW, + ("ping", "POST"): Scopes.UPDATE, + ("deliveries", "GET"): Scopes.VIEW, + ("retrieve_delivery", "GET"): Scopes.VIEW, + ("redelivery", "POST"): Scopes.UPDATE, }[(view.action, request.method)] scopes = [] if scope == Scopes.CREATE: - webhook_type = request.data.get('type') + webhook_type = request.data.get("type") if webhook_type in [m.value for m in WebhookTypeChoice]: - scope = Scopes(str(scope) + f'@{webhook_type}') + scope = Scopes(str(scope) + f"@{webhook_type}") scopes.append(scope) else: scopes.append(scope) @@ -80,42 +81,52 @@ def get_resource(self): if self.obj: data = { "id": self.obj.id, - "owner": {"id": getattr(self.obj.owner, 'id', None) }, - 'organization': { - "id": getattr(self.obj.organization, 'id', None) - }, - "project": None + "owner": {"id": getattr(self.obj.owner, "id", None)}, + "organization": {"id": getattr(self.obj.organization, "id", None)}, + "project": None, } - if self.obj.type == 'project' and getattr(self.obj, 'project', None): - data['project'] = { - 'owner': {'id': getattr(self.obj.project.owner, 'id', None)} - } + if self.obj.type == "project" and getattr(self.obj, "project", None): + data["project"] = {"owner": {"id": getattr(self.obj.project.owner, "id", None)}} elif self.scope in [ __class__.Scopes.CREATE, __class__.Scopes.CREATE_IN_PROJECT, - __class__.Scopes.CREATE_IN_ORG + __class__.Scopes.CREATE_IN_ORG, ]: project = None if self.project_id: try: project = Project.objects.get(id=self.project_id) except Project.DoesNotExist: - raise ValidationError(f"Could not find project with provided id: {self.project_id}") + raise ValidationError( + f"Could not find project with provided id: {self.project_id}" + ) data = { - 'id': None, - 'owner': self.user_id, - 'project': { - 'owner': { - 'id': project.owner.id, - } if project.owner else None, - } if project else None, - 'organization': { - 'id': self.org_id, - } if self.org_id is not None else None, - 'user': { - 'id': self.user_id, - } + "id": None, + "owner": self.user_id, + "project": ( + { + "owner": ( + { + "id": project.owner.id, + } + if project.owner + else None + ), + } + if project + else None + ), + "organization": ( + { + "id": self.org_id, + } + if self.org_id is not None + else None + ), + "user": { + "id": self.user_id, + }, } return data diff --git a/cvat/apps/webhooks/rules/tests/generators/webhooks_test.gen.rego.py b/cvat/apps/webhooks/rules/tests/generators/webhooks_test.gen.rego.py index 66417f3d096d..2913bb5a2a6a 100644 --- a/cvat/apps/webhooks/rules/tests/generators/webhooks_test.gen.rego.py +++ b/cvat/apps/webhooks/rules/tests/generators/webhooks_test.gen.rego.py @@ -125,13 +125,15 @@ def get_data(scope, context, ownership, privilege, membership, resource, same_or "scope": scope, "auth": { "user": {"id": random.randrange(0, 100), "privilege": privilege}, - "organization": { - "id": random.randrange(100, 200), - "owner": {"id": random.randrange(200, 300)}, - "user": {"role": membership}, - } - if context == "organization" - else None, + "organization": ( + { + "id": random.randrange(100, 200), + "owner": {"id": random.randrange(200, 300)}, + "user": {"role": membership}, + } + if context == "organization" + else None + ), }, "resource": resource, } diff --git a/cvat/apps/webhooks/serializers.py b/cvat/apps/webhooks/serializers.py index b3060986eefd..bd540de55fbd 100644 --- a/cvat/apps/webhooks/serializers.py +++ b/cvat/apps/webhooks/serializers.py @@ -30,9 +30,7 @@ def __call__(self, attrs, serializer): webhook_type == WebhookTypeChoice.ORGANIZATION and not events.issubset(set(OrganizationEvents.events)) ): - raise serializers.ValidationError( - f"Invalid events list for {webhook_type} webhook" - ) + raise serializers.ValidationError(f"Invalid events list for {webhook_type} webhook") class EventTypesSerializer(serializers.MultipleChoiceField): @@ -62,9 +60,7 @@ class WebhookReadSerializer(serializers.ModelSerializer): type = serializers.ChoiceField(choices=WebhookTypeChoice.choices()) content_type = serializers.ChoiceField(choices=WebhookContentTypeChoice.choices()) - last_status = serializers.IntegerField( - source="deliveries.last.status_code", read_only=True - ) + last_status = serializers.IntegerField(source="deliveries.last.status_code", read_only=True) last_delivery_date = serializers.DateTimeField( source="deliveries.last.updated_date", read_only=True @@ -99,9 +95,7 @@ class Meta: class WebhookWriteSerializer(WriteOnceMixin, serializers.ModelSerializer): events = EventTypesSerializer(write_only=True) - project_id = serializers.IntegerField( - write_only=True, allow_null=True, required=False - ) + project_id = serializers.IntegerField(write_only=True, allow_null=True, required=False) def to_representation(self, instance): serializer = WebhookReadSerializer(instance, context=self.context) @@ -124,8 +118,8 @@ class Meta: validators = [EventTypeValidator()] def create(self, validated_data): - if (project_id := validated_data.get('project_id')) is not None: - validated_data['organization'] = Project.objects.get(pk=project_id).organization + if (project_id := validated_data.get("project_id")) is not None: + validated_data["organization"] = Project.objects.get(pk=project_id).organization db_webhook = Webhook.objects.create(**validated_data) return db_webhook diff --git a/cvat/apps/webhooks/signals.py b/cvat/apps/webhooks/signals.py index eb14edcdb2c3..6e08e35192dd 100644 --- a/cvat/apps/webhooks/signals.py +++ b/cvat/apps/webhooks/signals.py @@ -38,6 +38,7 @@ signal_redelivery = Signal() signal_ping = Signal() + def send_webhook(webhook, payload, redelivery=False): headers = {} if webhook.secret: @@ -63,9 +64,7 @@ def send_webhook(webhook, payload, redelivery=False): proxies=PROXIES_FOR_UNTRUSTED_URLS, ) status_code = response.status_code - response_body = response.raw.read( - RESPONSE_SIZE_LIMIT + 1, decode_content=True - ) + response_body = response.raw.read(RESPONSE_SIZE_LIMIT + 1, decode_content=True) except requests.ConnectionError: status_code = HTTPStatus.BAD_GATEWAY except requests.Timeout: @@ -87,6 +86,7 @@ def send_webhook(webhook, payload, redelivery=False): return delivery + def add_to_queue(webhook, payload, redelivery=False): queue = django_rq.get_queue(settings.CVAT_QUEUES.WEBHOOKS.value) queue.enqueue_call(func=send_webhook, args=(webhook, payload, redelivery)) @@ -167,6 +167,7 @@ def pre_save_resource_event(sender, instance, **kwargs): old_serializer = get_serializer(instance=old_instance) instance._webhooks_old_data = old_serializer.data + @receiver(post_save, sender=Project, dispatch_uid=__name__ + ":project:post_save") @receiver(post_save, sender=Task, dispatch_uid=__name__ + ":task:post_save") @receiver(post_save, sender=Job, dispatch_uid=__name__ + ":job:post_save") @@ -200,10 +201,7 @@ def post_save_resource_event(sender, instance, **kwargs): if not created: if diff := get_instance_diff(old_data=old_data, data=serializer.data): - data["before_update"] = { - attr: value["old_value"] - for attr, value in diff.items() - } + data["before_update"] = {attr: value["old_value"] for attr, value in diff.items()} transaction.on_commit( lambda: batch_add_to_queue(selected_webhooks, data), @@ -254,7 +252,11 @@ def post_delete_resource_event(sender, instance, **kwargs): "sender": get_sender(instance), } - related_webhooks = [webhook for webhook in getattr(instance, "_related_webhooks", []) if webhook.id not in map(lambda a: a.id, filtered_webhooks)] + related_webhooks = [ + webhook + for webhook in getattr(instance, "_related_webhooks", []) + if webhook.id not in map(lambda a: a.id, filtered_webhooks) + ] transaction.on_commit( lambda: batch_add_to_queue(filtered_webhooks + related_webhooks, data), diff --git a/cvat/apps/webhooks/views.py b/cvat/apps/webhooks/views.py index 4c084b3f3541..b4e059c528f6 100644 --- a/cvat/apps/webhooks/views.py +++ b/cvat/apps/webhooks/views.py @@ -42,24 +42,18 @@ update=extend_schema( summary="Replace a webhook", request=WebhookWriteSerializer, - responses={ - "200": WebhookReadSerializer - }, # check WebhookWriteSerializer.to_representation + responses={"200": WebhookReadSerializer}, # check WebhookWriteSerializer.to_representation ), partial_update=extend_schema( summary="Update a webhook", request=WebhookWriteSerializer, - responses={ - "200": WebhookReadSerializer - }, # check WebhookWriteSerializer.to_representation + responses={"200": WebhookReadSerializer}, # check WebhookWriteSerializer.to_representation ), create=extend_schema( request=WebhookWriteSerializer, summary="Create a webhook", parameters=ORGANIZATION_OPEN_API_PARAMETERS, - responses={ - "201": WebhookReadSerializer - }, # check WebhookWriteSerializer.to_representation + responses={"201": WebhookReadSerializer}, # check WebhookWriteSerializer.to_representation ), destroy=extend_schema( summary="Delete a webhook", @@ -79,9 +73,7 @@ class WebhookViewSet(viewsets.ModelViewSet): iam_organization_field = "organization" def get_serializer_class(self): - if self.request.path.endswith("redelivery") or self.request.path.endswith( - "ping" - ): + if self.request.path.endswith("redelivery") or self.request.path.endswith("ping"): return None else: if self.request.method in SAFE_METHODS: @@ -117,7 +109,10 @@ def perform_create(self, serializer): ], responses={"200": OpenApiResponse(EventsSerializer)}, ) - @action(detail=False, methods=["GET"], serializer_class=EventsSerializer, + @action( + detail=False, + methods=["GET"], + serializer_class=EventsSerializer, permission_classes=[], ) def events(self, request): @@ -131,9 +126,7 @@ def events(self, request): events = OrganizationEvents if events is None: - return Response( - "Incorrect value of type parameter", status=status.HTTP_400_BAD_REQUEST - ) + return Response("Incorrect value of type parameter", status=status.HTTP_400_BAD_REQUEST) return Response(EventsSerializer().to_representation(events)) @@ -145,10 +138,8 @@ def events(self, request): ) @list_action(serializer_class=WebhookDeliveryReadSerializer) def deliveries(self, request, pk): - self.get_object() # force call of check_object_permissions() - queryset = WebhookDelivery.objects.filter(webhook_id=pk).order_by( - "-updated_date" - ) + self.get_object() # force call of check_object_permissions() + queryset = WebhookDelivery.objects.filter(webhook_id=pk).order_by("-updated_date") return make_paginated_response( queryset, viewset=self, serializer_type=self.serializer_class ) # from @action @@ -164,11 +155,9 @@ def deliveries(self, request, pk): serializer_class=WebhookDeliveryReadSerializer, ) def retrieve_delivery(self, request, pk, delivery_id): - self.get_object() # force call of check_object_permissions() + self.get_object() # force call of check_object_permissions() queryset = WebhookDelivery.objects.get(webhook_id=pk, id=delivery_id) - serializer = WebhookDeliveryReadSerializer( - queryset, context={"request": request} - ) + serializer = WebhookDeliveryReadSerializer(queryset, context={"request": request}) return Response(serializer.data) @extend_schema( @@ -192,15 +181,11 @@ def redelivery(self, request, pk, delivery_id): request=None, responses={"200": WebhookDeliveryReadSerializer}, ) - @action( - detail=True, methods=["POST"], serializer_class=WebhookDeliveryReadSerializer - ) + @action(detail=True, methods=["POST"], serializer_class=WebhookDeliveryReadSerializer) def ping(self, request, pk): - instance = self.get_object() # force call of check_object_permissions() + instance = self.get_object() # force call of check_object_permissions() serializer = WebhookReadSerializer(instance, context={"request": request}) delivery = signal_ping.send(sender=self, serializer=serializer)[0][1] - serializer = WebhookDeliveryReadSerializer( - delivery, context={"request": request} - ) + serializer = WebhookDeliveryReadSerializer(delivery, context={"request": request}) return Response(serializer.data) diff --git a/pyproject.toml b/pyproject.toml index 9447beefa868..b0c13a15766f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,11 +17,7 @@ line-length = 100 target-version = ['py39'] extend-exclude = """ # TODO: get rid of these -^/cvat/apps/( - dataset_manager|dataset_repo|engine|events - |health|iam|lambda_manager|log_viewer - |organizations|webhooks -)/ +^/cvat/apps/(dataset_manager|engine)/ | ^/cvat/settings/ | ^/serverless/ | ^/utils/dataset_manifest/ From c5f22720cf3430f2d3b302425f9d5fa8969eb000 Mon Sep 17 00:00:00 2001 From: Maxim Zhiltsov Date: Wed, 8 Jan 2025 13:21:23 +0300 Subject: [PATCH 26/31] Change match_empty_frames to empty_is_annotated (#8888) Improves the implementation of the `match_empty_frames` quality setting to work in cases when only GT or DS annotations are empty. This allows to use precision as the primary metric in cases when all frames are required to have at least 1 annotation. The previous implementation only affected matching empty annotations. The updated variant also counts any empty frames in `ds_count` and `gt_count` so that mismatched empty annotations also included in denominators of metrics. Example: | annotations per frame | old accuracy | new accuracy | old precision | new precision | | - | - | - | - | - | | ds: [empty, 1 valid]; gt: [empty, 1 valid] | 2/2 | 2/2 | 2/2 | 2/2 | | ds: [1 extra, 1 valid]; gt: [empty, 1 valid] | 1/2 (only matches) | 1/3 (empty != extra) | 1/2 | 1/2 | | ds: [empty, 1 valid]; gt: [1 miss, 1 valid] | 1/2 (only matches) | 1/3 (empty != miss) | 1/1 (only matches) | 1/2 | So, it allowed undesirable situations in which 1 and the only correct annotation in a job could be counted as 100% of precision. The updated option prevents this. --- ...1229_221630_mzhiltso_empty_is_annotated.md | 6 ++ cvat-core/src/quality-settings.ts | 14 ++-- cvat-core/src/server-response-types.ts | 2 +- .../quality-control/quality-control-page.tsx | 2 +- .../task-quality/quality-settings-form.tsx | 8 +-- ...ames_qualitysettings_empty_is_annotated.py | 18 +++++ cvat/apps/quality_control/models.py | 2 +- cvat/apps/quality_control/quality_reports.py | 71 ++++++++++++------- cvat/apps/quality_control/serializers.py | 10 +-- cvat/schema.yml | 12 ++-- .../analytics-and-monitoring/auto-qa.md | 2 +- tests/python/rest_api/test_quality_control.py | 7 +- tests/python/shared/assets/cvat_db/data.json | 48 ++++++------- .../shared/assets/quality_settings.json | 48 ++++++------- 14 files changed, 150 insertions(+), 100 deletions(-) create mode 100644 changelog.d/20241229_221630_mzhiltso_empty_is_annotated.md create mode 100644 cvat/apps/quality_control/migrations/0006_rename_match_empty_frames_qualitysettings_empty_is_annotated.py diff --git a/changelog.d/20241229_221630_mzhiltso_empty_is_annotated.md b/changelog.d/20241229_221630_mzhiltso_empty_is_annotated.md new file mode 100644 index 000000000000..63746abc86e2 --- /dev/null +++ b/changelog.d/20241229_221630_mzhiltso_empty_is_annotated.md @@ -0,0 +1,6 @@ +### Changed + +- The `match_empty_frames` quality setting is changed to `empty_is_annotated`. + The updated option includes any empty frames in the final metrics instead of only + matching empty frames. This makes metrics such as Precision much more representative and useful. + () diff --git a/cvat-core/src/quality-settings.ts b/cvat-core/src/quality-settings.ts index 7c591e371cc4..bc553105c181 100644 --- a/cvat-core/src/quality-settings.ts +++ b/cvat-core/src/quality-settings.ts @@ -38,7 +38,7 @@ export default class QualitySettings { #objectVisibilityThreshold: number; #panopticComparison: boolean; #compareAttributes: boolean; - #matchEmptyFrames: boolean; + #emptyIsAnnotated: boolean; #descriptions: Record; constructor(initialData: SerializedQualitySettingsData) { @@ -60,7 +60,7 @@ export default class QualitySettings { this.#objectVisibilityThreshold = initialData.object_visibility_threshold; this.#panopticComparison = initialData.panoptic_comparison; this.#compareAttributes = initialData.compare_attributes; - this.#matchEmptyFrames = initialData.match_empty_frames; + this.#emptyIsAnnotated = initialData.empty_is_annotated; this.#descriptions = initialData.descriptions; } @@ -200,12 +200,12 @@ export default class QualitySettings { this.#maxValidationsPerJob = newVal; } - get matchEmptyFrames(): boolean { - return this.#matchEmptyFrames; + get emptyIsAnnotated(): boolean { + return this.#emptyIsAnnotated; } - set matchEmptyFrames(newVal: boolean) { - this.#matchEmptyFrames = newVal; + set emptyIsAnnotated(newVal: boolean) { + this.#emptyIsAnnotated = newVal; } get descriptions(): Record { @@ -236,7 +236,7 @@ export default class QualitySettings { target_metric: this.#targetMetric, target_metric_threshold: this.#targetMetricThreshold, max_validations_per_job: this.#maxValidationsPerJob, - match_empty_frames: this.#matchEmptyFrames, + empty_is_annotated: this.#emptyIsAnnotated, }; return result; diff --git a/cvat-core/src/server-response-types.ts b/cvat-core/src/server-response-types.ts index ea97c0730aaa..ef635d12004e 100644 --- a/cvat-core/src/server-response-types.ts +++ b/cvat-core/src/server-response-types.ts @@ -258,7 +258,7 @@ export interface SerializedQualitySettingsData { object_visibility_threshold?: number; panoptic_comparison?: boolean; compare_attributes?: boolean; - match_empty_frames?: boolean; + empty_is_annotated?: boolean; descriptions?: Record; } diff --git a/cvat-ui/src/components/quality-control/quality-control-page.tsx b/cvat-ui/src/components/quality-control/quality-control-page.tsx index cbaa26a8dd09..afa166f6f5fa 100644 --- a/cvat-ui/src/components/quality-control/quality-control-page.tsx +++ b/cvat-ui/src/components/quality-control/quality-control-page.tsx @@ -223,7 +223,7 @@ function QualityControlPage(): JSX.Element { settings.lowOverlapThreshold = values.lowOverlapThreshold / 100; settings.iouThreshold = values.iouThreshold / 100; settings.compareAttributes = values.compareAttributes; - settings.matchEmptyFrames = values.matchEmptyFrames; + settings.emptyIsAnnotated = values.emptyIsAnnotated; settings.oksSigma = values.oksSigma / 100; settings.pointSizeBase = values.pointSizeBase; diff --git a/cvat-ui/src/components/quality-control/task-quality/quality-settings-form.tsx b/cvat-ui/src/components/quality-control/task-quality/quality-settings-form.tsx index 87a727f9772b..b5218475b418 100644 --- a/cvat-ui/src/components/quality-control/task-quality/quality-settings-form.tsx +++ b/cvat-ui/src/components/quality-control/task-quality/quality-settings-form.tsx @@ -34,7 +34,7 @@ export default function QualitySettingsForm(props: Readonly): JSX.Element lowOverlapThreshold: settings.lowOverlapThreshold * 100, iouThreshold: settings.iouThreshold * 100, compareAttributes: settings.compareAttributes, - matchEmptyFrames: settings.matchEmptyFrames, + emptyIsAnnotated: settings.emptyIsAnnotated, oksSigma: settings.oksSigma * 100, pointSizeBase: settings.pointSizeBase, @@ -81,7 +81,7 @@ export default function QualitySettingsForm(props: Readonly): JSX.Element {makeTooltipFragment('Target metric', targetMetricDescription)} {makeTooltipFragment('Target metric threshold', settings.descriptions.targetMetricThreshold)} {makeTooltipFragment('Compare attributes', settings.descriptions.compareAttributes)} - {makeTooltipFragment('Match empty frames', settings.descriptions.matchEmptyFrames)} + {makeTooltipFragment('Empty frames are annotated', settings.descriptions.emptyIsAnnotated)} , ); @@ -198,12 +198,12 @@ export default function QualitySettingsForm(props: Readonly): JSX.Element - Match empty frames + Empty frames are annotated diff --git a/cvat/apps/quality_control/migrations/0006_rename_match_empty_frames_qualitysettings_empty_is_annotated.py b/cvat/apps/quality_control/migrations/0006_rename_match_empty_frames_qualitysettings_empty_is_annotated.py new file mode 100644 index 000000000000..ea2f74927309 --- /dev/null +++ b/cvat/apps/quality_control/migrations/0006_rename_match_empty_frames_qualitysettings_empty_is_annotated.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.15 on 2024-12-29 19:08 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("quality_control", "0005_qualitysettings_match_empty"), + ] + + operations = [ + migrations.RenameField( + model_name="qualitysettings", + old_name="match_empty_frames", + new_name="empty_is_annotated", + ), + ] diff --git a/cvat/apps/quality_control/models.py b/cvat/apps/quality_control/models.py index a5359e4fe944..c521ac276f31 100644 --- a/cvat/apps/quality_control/models.py +++ b/cvat/apps/quality_control/models.py @@ -235,7 +235,7 @@ class QualitySettings(models.Model): compare_attributes = models.BooleanField() - match_empty_frames = models.BooleanField(default=False) + empty_is_annotated = models.BooleanField(default=False) target_metric = models.CharField( max_length=32, diff --git a/cvat/apps/quality_control/quality_reports.py b/cvat/apps/quality_control/quality_reports.py index 25b5c962dc26..f757aeabc61a 100644 --- a/cvat/apps/quality_control/quality_reports.py +++ b/cvat/apps/quality_control/quality_reports.py @@ -215,10 +215,11 @@ class ComparisonParameters(_Serializable): panoptic_comparison: bool = True "Use only the visible part of the masks and polygons in comparisons" - match_empty_frames: bool = False + empty_is_annotated: bool = False """ - Consider unannotated (empty) frames as matching. If disabled, quality metrics, such as accuracy, - will be 0 if both GT and DS frames have no annotations. When enabled, they will be 1 instead. + Consider unannotated (empty) frames virtually annotated as "nothing". + If disabled, quality metrics, such as accuracy, will be 0 if both GT and DS frames + have no annotations. When enabled, they will be 1 instead. This will also add virtual annotations to empty frames in the comparison results. """ @@ -1977,15 +1978,20 @@ def _find_closest_unmatched_shape(shape: dm.Annotation): gt_label_idx = label_id_map[gt_ann.label] if gt_ann else self._UNMATCHED_IDX confusion_matrix[ds_label_idx, gt_label_idx] += 1 - if self.settings.match_empty_frames and not gt_item.annotations and not ds_item.annotations: + if self.settings.empty_is_annotated: # Add virtual annotations for empty frames - valid_labels_count = 1 - total_labels_count = 1 + if not gt_item.annotations and not ds_item.annotations: + valid_labels_count = 1 + total_labels_count = 1 - valid_shapes_count = 1 - total_shapes_count = 1 - ds_shapes_count = 1 - gt_shapes_count = 1 + valid_shapes_count = 1 + total_shapes_count = 1 + + if not ds_item.annotations: + ds_shapes_count = 1 + + if not gt_item.annotations: + gt_shapes_count = 1 self._frame_results[frame_id] = ComparisonReportFrameSummary( annotations=self._generate_frame_annotations_summary( @@ -2078,12 +2084,17 @@ def _generate_frame_annotations_summary( ) -> ComparisonReportAnnotationsSummary: summary = self._compute_annotations_summary(confusion_matrix, confusion_matrix_labels) - if self.settings.match_empty_frames and summary.total_count == 0: + if self.settings.empty_is_annotated: # Add virtual annotations for empty frames - summary.valid_count = 1 - summary.total_count = 1 - summary.ds_count = 1 - summary.gt_count = 1 + if not summary.total_count: + summary.valid_count = 1 + summary.total_count = 1 + + if not summary.ds_count: + summary.ds_count = 1 + + if not summary.gt_count: + summary.gt_count = 1 return summary @@ -2108,14 +2119,26 @@ def _generate_dataset_annotations_summary( ), ) mean_ious = [] - empty_frame_count = 0 + empty_gt_frames = set() + empty_ds_frames = set() confusion_matrix_labels, confusion_matrix, _ = self._make_zero_confusion_matrix() - for frame_result in frame_summaries.values(): + for frame_id, frame_result in frame_summaries.items(): confusion_matrix += frame_result.annotations.confusion_matrix.rows - if not np.any(frame_result.annotations.confusion_matrix.rows): - empty_frame_count += 1 + if self.settings.empty_is_annotated and not np.any( + frame_result.annotations.confusion_matrix.rows[ + np.triu_indices_from(frame_result.annotations.confusion_matrix.rows) + ] + ): + empty_ds_frames.add(frame_id) + + if self.settings.empty_is_annotated and not np.any( + frame_result.annotations.confusion_matrix.rows[ + np.tril_indices_from(frame_result.annotations.confusion_matrix.rows) + ] + ): + empty_gt_frames.add(frame_id) if annotation_components is None: annotation_components = deepcopy(frame_result.annotation_components) @@ -2128,13 +2151,13 @@ def _generate_dataset_annotations_summary( confusion_matrix, confusion_matrix_labels ) - if self.settings.match_empty_frames and empty_frame_count: + if self.settings.empty_is_annotated: # Add virtual annotations for empty frames, # they are not included in the confusion matrix - annotation_summary.valid_count += empty_frame_count - annotation_summary.total_count += empty_frame_count - annotation_summary.ds_count += empty_frame_count - annotation_summary.gt_count += empty_frame_count + annotation_summary.valid_count += len(empty_ds_frames & empty_gt_frames) + annotation_summary.total_count += len(empty_ds_frames | empty_gt_frames) + annotation_summary.ds_count += len(empty_ds_frames) + annotation_summary.gt_count += len(empty_gt_frames) # Cannot be computed in accumulate() annotation_components.shape.mean_iou = np.mean(mean_ious) diff --git a/cvat/apps/quality_control/serializers.py b/cvat/apps/quality_control/serializers.py index 6164abc12200..11a5e0d8b02e 100644 --- a/cvat/apps/quality_control/serializers.py +++ b/cvat/apps/quality_control/serializers.py @@ -92,7 +92,7 @@ class Meta: "object_visibility_threshold", "panoptic_comparison", "compare_attributes", - "match_empty_frames", + "empty_is_annotated", ) read_only_fields = ( "id", @@ -100,7 +100,7 @@ class Meta: ) extra_kwargs = {k: {"required": False} for k in fields} - extra_kwargs.setdefault("match_empty_frames", {}).setdefault("default", False) + extra_kwargs.setdefault("empty_is_annotated", {}).setdefault("default", False) for field_name, help_text in { "target_metric": "The primary metric used for quality estimation", @@ -166,9 +166,9 @@ class Meta: Use only the visible part of the masks and polygons in comparisons """, "compare_attributes": "Enables or disables annotation attribute comparison", - "match_empty_frames": """ - Count empty frames as matching. This affects target metrics like accuracy in cases - there are no annotations. If disabled, frames without annotations + "empty_is_annotated": """ + Consider empty frames annotated as "empty". This affects target metrics like + accuracy in cases there are no annotations. If disabled, frames without annotations are counted as not matching (accuracy is 0). If enabled, accuracy will be 1 instead. This will also add virtual annotations to empty frames in the comparison results. """, diff --git a/cvat/schema.yml b/cvat/schema.yml index 8af068ecc8b2..3da1b323a14b 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -9775,12 +9775,12 @@ components: compare_attributes: type: boolean description: Enables or disables annotation attribute comparison - match_empty_frames: + empty_is_annotated: type: boolean default: false description: | - Count empty frames as matching. This affects target metrics like accuracy in cases - there are no annotations. If disabled, frames without annotations + Consider empty frames annotated as "empty". This affects target metrics like + accuracy in cases there are no annotations. If disabled, frames without annotations are counted as not matching (accuracy is 0). If enabled, accuracy will be 1 instead. This will also add virtual annotations to empty frames in the comparison results. PatchedTaskValidationLayoutWriteRequest: @@ -10282,12 +10282,12 @@ components: compare_attributes: type: boolean description: Enables or disables annotation attribute comparison - match_empty_frames: + empty_is_annotated: type: boolean default: false description: | - Count empty frames as matching. This affects target metrics like accuracy in cases - there are no annotations. If disabled, frames without annotations + Consider empty frames annotated as "empty". This affects target metrics like + accuracy in cases there are no annotations. If disabled, frames without annotations are counted as not matching (accuracy is 0). If enabled, accuracy will be 1 instead. This will also add virtual annotations to empty frames in the comparison results. RegisterSerializerEx: diff --git a/site/content/en/docs/manual/advanced/analytics-and-monitoring/auto-qa.md b/site/content/en/docs/manual/advanced/analytics-and-monitoring/auto-qa.md index 21ebd2d99087..4a098c6545fa 100644 --- a/site/content/en/docs/manual/advanced/analytics-and-monitoring/auto-qa.md +++ b/site/content/en/docs/manual/advanced/analytics-and-monitoring/auto-qa.md @@ -385,7 +385,7 @@ Annotation quality settings have the following parameters: | - | - | | Min overlap threshold | Min overlap threshold used for the distinction between matched and unmatched shapes. Used to match all types of annotations. It corresponds to the Intersection over union (IoU) for spatial annotations, such as bounding boxes and masks. | | Low overlap threshold | Low overlap threshold used for the distinction between strong and weak matches. Only affects _Low overlap_ warnings. It's supposed that _Min similarity threshold_ <= _Low overlap threshold_. | -| Match empty frames | Consider frames matched if there are no annotations both on GT and regular job frames | +| Empty frames are annotated | Consider frames annotated as "empty" if there are no annotations on a frame. If a frame is empty in both GT and job annotations, it will be considered a matching annotation. | | _Point and Skeleton matching_ | | | - | - | diff --git a/tests/python/rest_api/test_quality_control.py b/tests/python/rest_api/test_quality_control.py index d03675c9156e..56dd24bb0abb 100644 --- a/tests/python/rest_api/test_quality_control.py +++ b/tests/python/rest_api/test_quality_control.py @@ -1213,7 +1213,7 @@ def test_modified_task_produces_different_metrics( "compare_line_orientation", "panoptic_comparison", "point_size_base", - "match_empty_frames", + "empty_is_annotated", ], ) def test_settings_affect_metrics( @@ -1246,8 +1246,11 @@ def test_settings_affect_metrics( ) new_report = self.create_quality_report(admin_user, task_id) - if parameter == "match_empty_frames": + if parameter == "empty_is_annotated": assert new_report["summary"]["valid_count"] != old_report["summary"]["valid_count"] + assert new_report["summary"]["total_count"] != old_report["summary"]["total_count"] + assert new_report["summary"]["ds_count"] != old_report["summary"]["ds_count"] + assert new_report["summary"]["gt_count"] != old_report["summary"]["gt_count"] else: assert ( new_report["summary"]["conflict_count"] != old_report["summary"]["conflict_count"] diff --git a/tests/python/shared/assets/cvat_db/data.json b/tests/python/shared/assets/cvat_db/data.json index 5b30d421cb5a..53863fa94fcc 100644 --- a/tests/python/shared/assets/cvat_db/data.json +++ b/tests/python/shared/assets/cvat_db/data.json @@ -18173,7 +18173,7 @@ "object_visibility_threshold": 0.05, "panoptic_comparison": true, "compare_attributes": true, - "match_empty_frames": false, + "empty_is_annotated": false, "target_metric": "accuracy", "target_metric_threshold": 0.7, "max_validations_per_job": 0 @@ -18197,7 +18197,7 @@ "object_visibility_threshold": 0.05, "panoptic_comparison": true, "compare_attributes": true, - "match_empty_frames": false, + "empty_is_annotated": false, "target_metric": "accuracy", "target_metric_threshold": 0.7, "max_validations_per_job": 0 @@ -18221,7 +18221,7 @@ "object_visibility_threshold": 0.05, "panoptic_comparison": true, "compare_attributes": true, - "match_empty_frames": false, + "empty_is_annotated": false, "target_metric": "accuracy", "target_metric_threshold": 0.7, "max_validations_per_job": 0 @@ -18245,7 +18245,7 @@ "object_visibility_threshold": 0.05, "panoptic_comparison": true, "compare_attributes": true, - "match_empty_frames": false, + "empty_is_annotated": false, "target_metric": "accuracy", "target_metric_threshold": 0.7, "max_validations_per_job": 0 @@ -18269,7 +18269,7 @@ "object_visibility_threshold": 0.05, "panoptic_comparison": true, "compare_attributes": true, - "match_empty_frames": false, + "empty_is_annotated": false, "target_metric": "accuracy", "target_metric_threshold": 0.7, "max_validations_per_job": 0 @@ -18293,7 +18293,7 @@ "object_visibility_threshold": 0.05, "panoptic_comparison": true, "compare_attributes": true, - "match_empty_frames": false, + "empty_is_annotated": false, "target_metric": "accuracy", "target_metric_threshold": 0.7, "max_validations_per_job": 0 @@ -18317,7 +18317,7 @@ "object_visibility_threshold": 0.05, "panoptic_comparison": true, "compare_attributes": true, - "match_empty_frames": false, + "empty_is_annotated": false, "target_metric": "accuracy", "target_metric_threshold": 0.7, "max_validations_per_job": 0 @@ -18341,7 +18341,7 @@ "object_visibility_threshold": 0.05, "panoptic_comparison": true, "compare_attributes": true, - "match_empty_frames": false, + "empty_is_annotated": false, "target_metric": "accuracy", "target_metric_threshold": 0.7, "max_validations_per_job": 0 @@ -18365,7 +18365,7 @@ "object_visibility_threshold": 0.05, "panoptic_comparison": true, "compare_attributes": true, - "match_empty_frames": false, + "empty_is_annotated": false, "target_metric": "accuracy", "target_metric_threshold": 0.7, "max_validations_per_job": 0 @@ -18389,7 +18389,7 @@ "object_visibility_threshold": 0.05, "panoptic_comparison": true, "compare_attributes": true, - "match_empty_frames": false, + "empty_is_annotated": false, "target_metric": "accuracy", "target_metric_threshold": 0.7, "max_validations_per_job": 0 @@ -18413,7 +18413,7 @@ "object_visibility_threshold": 0.05, "panoptic_comparison": true, "compare_attributes": true, - "match_empty_frames": false, + "empty_is_annotated": false, "target_metric": "accuracy", "target_metric_threshold": 0.7, "max_validations_per_job": 0 @@ -18437,7 +18437,7 @@ "object_visibility_threshold": 0.05, "panoptic_comparison": true, "compare_attributes": true, - "match_empty_frames": false, + "empty_is_annotated": false, "target_metric": "accuracy", "target_metric_threshold": 0.7, "max_validations_per_job": 0 @@ -18461,7 +18461,7 @@ "object_visibility_threshold": 0.05, "panoptic_comparison": true, "compare_attributes": true, - "match_empty_frames": false, + "empty_is_annotated": false, "target_metric": "accuracy", "target_metric_threshold": 0.7, "max_validations_per_job": 0 @@ -18485,7 +18485,7 @@ "object_visibility_threshold": 0.05, "panoptic_comparison": true, "compare_attributes": true, - "match_empty_frames": false, + "empty_is_annotated": false, "target_metric": "accuracy", "target_metric_threshold": 0.7, "max_validations_per_job": 0 @@ -18509,7 +18509,7 @@ "object_visibility_threshold": 0.05, "panoptic_comparison": true, "compare_attributes": true, - "match_empty_frames": false, + "empty_is_annotated": false, "target_metric": "accuracy", "target_metric_threshold": 0.7, "max_validations_per_job": 0 @@ -18533,7 +18533,7 @@ "object_visibility_threshold": 0.05, "panoptic_comparison": true, "compare_attributes": true, - "match_empty_frames": false, + "empty_is_annotated": false, "target_metric": "accuracy", "target_metric_threshold": 0.7, "max_validations_per_job": 0 @@ -18557,7 +18557,7 @@ "object_visibility_threshold": 0.05, "panoptic_comparison": true, "compare_attributes": true, - "match_empty_frames": false, + "empty_is_annotated": false, "target_metric": "accuracy", "target_metric_threshold": 0.7, "max_validations_per_job": 0 @@ -18581,7 +18581,7 @@ "object_visibility_threshold": 0.05, "panoptic_comparison": true, "compare_attributes": true, - "match_empty_frames": false, + "empty_is_annotated": false, "target_metric": "accuracy", "target_metric_threshold": 0.7, "max_validations_per_job": 0 @@ -18605,7 +18605,7 @@ "object_visibility_threshold": 0.05, "panoptic_comparison": true, "compare_attributes": true, - "match_empty_frames": false, + "empty_is_annotated": false, "target_metric": "accuracy", "target_metric_threshold": 0.7, "max_validations_per_job": 0 @@ -18629,7 +18629,7 @@ "object_visibility_threshold": 0.05, "panoptic_comparison": true, "compare_attributes": true, - "match_empty_frames": false, + "empty_is_annotated": false, "target_metric": "accuracy", "target_metric_threshold": 0.7, "max_validations_per_job": 0 @@ -18653,7 +18653,7 @@ "object_visibility_threshold": 0.05, "panoptic_comparison": true, "compare_attributes": true, - "match_empty_frames": false, + "empty_is_annotated": false, "target_metric": "accuracy", "target_metric_threshold": 0.7, "max_validations_per_job": 0 @@ -18677,7 +18677,7 @@ "object_visibility_threshold": 0.05, "panoptic_comparison": true, "compare_attributes": true, - "match_empty_frames": false, + "empty_is_annotated": false, "target_metric": "accuracy", "target_metric_threshold": 0.7, "max_validations_per_job": 0 @@ -18701,7 +18701,7 @@ "object_visibility_threshold": 0.05, "panoptic_comparison": true, "compare_attributes": true, - "match_empty_frames": false, + "empty_is_annotated": false, "target_metric": "accuracy", "target_metric_threshold": 0.7, "max_validations_per_job": 0 @@ -18725,7 +18725,7 @@ "object_visibility_threshold": 0.05, "panoptic_comparison": true, "compare_attributes": true, - "match_empty_frames": false, + "empty_is_annotated": false, "target_metric": "accuracy", "target_metric_threshold": 0.7, "max_validations_per_job": 0 diff --git a/tests/python/shared/assets/quality_settings.json b/tests/python/shared/assets/quality_settings.json index 7ddc589bc7bf..dc56352fc1ef 100644 --- a/tests/python/shared/assets/quality_settings.json +++ b/tests/python/shared/assets/quality_settings.json @@ -14,7 +14,7 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, - "match_empty_frames": false, + "empty_is_annotated": false, "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, @@ -35,7 +35,7 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, - "match_empty_frames": false, + "empty_is_annotated": false, "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, @@ -56,7 +56,7 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, - "match_empty_frames": false, + "empty_is_annotated": false, "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, @@ -77,7 +77,7 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, - "match_empty_frames": false, + "empty_is_annotated": false, "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, @@ -98,7 +98,7 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, - "match_empty_frames": false, + "empty_is_annotated": false, "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, @@ -119,7 +119,7 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, - "match_empty_frames": false, + "empty_is_annotated": false, "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, @@ -140,7 +140,7 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, - "match_empty_frames": false, + "empty_is_annotated": false, "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, @@ -161,7 +161,7 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, - "match_empty_frames": false, + "empty_is_annotated": false, "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, @@ -182,7 +182,7 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, - "match_empty_frames": false, + "empty_is_annotated": false, "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, @@ -203,7 +203,7 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, - "match_empty_frames": false, + "empty_is_annotated": false, "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, @@ -224,7 +224,7 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, - "match_empty_frames": false, + "empty_is_annotated": false, "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, @@ -245,7 +245,7 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, - "match_empty_frames": false, + "empty_is_annotated": false, "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, @@ -266,7 +266,7 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, - "match_empty_frames": false, + "empty_is_annotated": false, "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, @@ -287,7 +287,7 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, - "match_empty_frames": false, + "empty_is_annotated": false, "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, @@ -308,7 +308,7 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, - "match_empty_frames": false, + "empty_is_annotated": false, "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, @@ -329,7 +329,7 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, - "match_empty_frames": false, + "empty_is_annotated": false, "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, @@ -350,7 +350,7 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, - "match_empty_frames": false, + "empty_is_annotated": false, "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, @@ -371,7 +371,7 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, - "match_empty_frames": false, + "empty_is_annotated": false, "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, @@ -392,7 +392,7 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, - "match_empty_frames": false, + "empty_is_annotated": false, "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, @@ -413,7 +413,7 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, - "match_empty_frames": false, + "empty_is_annotated": false, "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, @@ -434,7 +434,7 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, - "match_empty_frames": false, + "empty_is_annotated": false, "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, @@ -455,7 +455,7 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, - "match_empty_frames": false, + "empty_is_annotated": false, "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, @@ -476,7 +476,7 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, - "match_empty_frames": false, + "empty_is_annotated": false, "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, @@ -497,7 +497,7 @@ "line_orientation_threshold": 0.1, "line_thickness": 0.01, "low_overlap_threshold": 0.8, - "match_empty_frames": false, + "empty_is_annotated": false, "max_validations_per_job": 0, "object_visibility_threshold": 0.05, "oks_sigma": 0.09, From c16477138e6dfb865a71090553d25c5ebfa51684 Mon Sep 17 00:00:00 2001 From: Roman Donchenko Date: Wed, 8 Jan 2025 13:52:41 +0200 Subject: [PATCH 27/31] Make it easier to execute commands in the server container from tests (#8908) I might add another test that uses this. --- tests/python/rest_api/test_tasks.py | 7 ++----- tests/python/shared/fixtures/init.py | 10 ++++++++++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/python/rest_api/test_tasks.py b/tests/python/rest_api/test_tasks.py index 70d8a84827bb..a55bd1ded65b 100644 --- a/tests/python/rest_api/test_tasks.py +++ b/tests/python/rest_api/test_tasks.py @@ -45,7 +45,7 @@ from pytest_cases import fixture, fixture_ref, parametrize import shared.utils.s3 as s3 -from shared.fixtures.init import docker_exec_cvat, kube_exec_cvat +from shared.fixtures.init import container_exec_cvat from shared.utils.config import ( delete_method, get_method, @@ -5315,12 +5315,9 @@ def test_check_import_cache_after_previous_interrupted_upload(self, tasks_with_s number_of_files = 1 sleep(30) # wait when the cleaning job from rq worker will be started command = ["/bin/bash", "-c", f"ls data/tasks/{task_id}/tmp | wc -l"] - platform = request.config.getoption("--platform") - assert platform in ("kube", "local") - func = docker_exec_cvat if platform == "local" else kube_exec_cvat for _ in range(12): sleep(2) - result, _ = func(command) + result, _ = container_exec_cvat(request, command) number_of_files = int(result) if not number_of_files: break diff --git a/tests/python/shared/fixtures/init.py b/tests/python/shared/fixtures/init.py index 1f5d57ffc5d7..b0d5f8a84db0 100644 --- a/tests/python/shared/fixtures/init.py +++ b/tests/python/shared/fixtures/init.py @@ -171,6 +171,16 @@ def kube_exec_cvat(command: Union[list[str], str]): return _run(_command) +def container_exec_cvat(request: pytest.FixtureRequest, command: Union[list[str], str]): + platform = request.config.getoption("--platform") + if platform == "local": + return docker_exec_cvat(command) + elif platform == "kube": + return kube_exec_cvat(command) + else: + assert False, "unknown platform" + + def kube_exec_cvat_db(command): pod_name = _kube_get_db_pod_name() _run(["kubectl", "exec", pod_name, "--"] + command) From 331ff86a3bd998975f238418287b7b47eb7a8369 Mon Sep 17 00:00:00 2001 From: Dmitrii Lavrukhin Date: Wed, 8 Jan 2025 16:07:50 +0400 Subject: [PATCH 28/31] preserving original rotation for export-import in yolo oriented boxes (#8891) ### Motivation and context fixes https://github.com/cvat-ai/cvat/issues/8882 depend on https://github.com/cvat-ai/datumaro/pull/72 ### How has this been tested? ### Checklist - [ ] I submit my changes into the `develop` branch - [ ] I have created a changelog fragment - [ ] I have updated the documentation accordingly - [ ] I have added tests to cover my changes - [ ] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) - [ ] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/cvat-ai/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/cvat-ai/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/cvat-ai/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/cvat-ai/cvat/tree/develop/cvat-ui#versioning)) ### License - [ ] I submit _my code changes_ under the same [MIT License]( https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. ## Summary by CodeRabbit - **Chores** - Updated `datumaro` dependency to a new commit hash in project requirements files. --- ..._124804_dmitrii.lavrukhin_yolo_preserve_rotation.md | 4 ++++ cvat/apps/dataset_manager/formats/cityscapes.py | 2 +- cvat/apps/dataset_manager/formats/coco.py | 2 +- cvat/apps/dataset_manager/formats/cvat.py | 2 +- cvat/apps/dataset_manager/formats/kitti.py | 2 +- cvat/apps/dataset_manager/formats/openimages.py | 2 +- cvat/requirements/base.in | 2 +- cvat/requirements/base.txt | 10 +++++----- cvat/requirements/production.txt | 2 +- 9 files changed, 16 insertions(+), 12 deletions(-) create mode 100644 changelog.d/20250108_124804_dmitrii.lavrukhin_yolo_preserve_rotation.md diff --git a/changelog.d/20250108_124804_dmitrii.lavrukhin_yolo_preserve_rotation.md b/changelog.d/20250108_124804_dmitrii.lavrukhin_yolo_preserve_rotation.md new file mode 100644 index 000000000000..57eafc77f2b7 --- /dev/null +++ b/changelog.d/20250108_124804_dmitrii.lavrukhin_yolo_preserve_rotation.md @@ -0,0 +1,4 @@ +### Fixed + +- Changing rotation after export/import in Ultralytics YOLO Oriented Boxes format + () diff --git a/cvat/apps/dataset_manager/formats/cityscapes.py b/cvat/apps/dataset_manager/formats/cityscapes.py index 6de867e551c6..dce977b94d1a 100644 --- a/cvat/apps/dataset_manager/formats/cityscapes.py +++ b/cvat/apps/dataset_manager/formats/cityscapes.py @@ -6,7 +6,7 @@ import os.path as osp from datumaro.components.dataset import Dataset -from datumaro.plugins.cityscapes_format import write_label_map +from datumaro.plugins.data_formats.cityscapes import write_label_map from pyunpack import Archive from cvat.apps.dataset_manager.bindings import ( diff --git a/cvat/apps/dataset_manager/formats/coco.py b/cvat/apps/dataset_manager/formats/coco.py index bddb33b08ba9..cab74bcb42e1 100644 --- a/cvat/apps/dataset_manager/formats/coco.py +++ b/cvat/apps/dataset_manager/formats/coco.py @@ -7,7 +7,7 @@ from datumaro.components.annotation import AnnotationType from datumaro.components.dataset import Dataset -from datumaro.plugins.coco_format.importer import CocoImporter +from datumaro.plugins.data_formats.coco.importer import CocoImporter from cvat.apps.dataset_manager.bindings import ( GetCVATDataExtractor, diff --git a/cvat/apps/dataset_manager/formats/cvat.py b/cvat/apps/dataset_manager/formats/cvat.py index 58a8076f01cc..f5c7dc18fcda 100644 --- a/cvat/apps/dataset_manager/formats/cvat.py +++ b/cvat/apps/dataset_manager/formats/cvat.py @@ -23,7 +23,7 @@ ) from datumaro.components.dataset import Dataset, DatasetItem from datumaro.components.extractor import DEFAULT_SUBSET_NAME, Extractor, Importer -from datumaro.plugins.cvat_format.extractor import CvatImporter as _CvatImporter +from datumaro.plugins.data_formats.cvat.base import CvatImporter as _CvatImporter from datumaro.util.image import Image from defusedxml import ElementTree diff --git a/cvat/apps/dataset_manager/formats/kitti.py b/cvat/apps/dataset_manager/formats/kitti.py index 0bdfdc4ab6e6..631f903f7289 100644 --- a/cvat/apps/dataset_manager/formats/kitti.py +++ b/cvat/apps/dataset_manager/formats/kitti.py @@ -6,7 +6,7 @@ import os.path as osp from datumaro.components.dataset import Dataset -from datumaro.plugins.kitti_format.format import KittiPath, write_label_map +from datumaro.plugins.data_formats.kitti.format import KittiPath, write_label_map from pyunpack import Archive from cvat.apps.dataset_manager.bindings import ( diff --git a/cvat/apps/dataset_manager/formats/openimages.py b/cvat/apps/dataset_manager/formats/openimages.py index c383a64e188a..2ae544238ee2 100644 --- a/cvat/apps/dataset_manager/formats/openimages.py +++ b/cvat/apps/dataset_manager/formats/openimages.py @@ -7,7 +7,7 @@ import os.path as osp from datumaro.components.dataset import Dataset, DatasetItem -from datumaro.plugins.open_images_format import OpenImagesPath +from datumaro.plugins.data_formats.open_images import OpenImagesPath from datumaro.util.image import DEFAULT_IMAGE_META_FILE_NAME from pyunpack import Archive diff --git a/cvat/requirements/base.in b/cvat/requirements/base.in index b373d315055a..03d74579fb21 100644 --- a/cvat/requirements/base.in +++ b/cvat/requirements/base.in @@ -12,7 +12,7 @@ azure-storage-blob==12.13.0 boto3==1.17.61 clickhouse-connect==0.6.8 coreapi==2.3.3 -datumaro @ git+https://github.com/cvat-ai/datumaro.git@fad7636b79889f0c7b8fb0c3010b894324c4c18a +datumaro @ git+https://github.com/cvat-ai/datumaro.git@08e77b216080555a57e12c01625be8c8201e3131 dj-pagination==2.5.0 # Despite direct indication allauth in requirements we should keep 'with_social' for dj-rest-auth # to avoid possible further versions conflicts (we use registration functionality) diff --git a/cvat/requirements/base.txt b/cvat/requirements/base.txt index 94ec4c140c95..f531f125ebf6 100644 --- a/cvat/requirements/base.txt +++ b/cvat/requirements/base.txt @@ -1,4 +1,4 @@ -# SHA1:d210065224fc86c1fba6b5d22b77ab38d02bcbb1 +# SHA1:3e6349d9e5e095c5a1f196eca66b3e5ba8672458 # # This file is autogenerated by pip-compile-multi # To update, run: @@ -56,7 +56,7 @@ cryptography==44.0.0 # pyjwt cycler==0.12.1 # via matplotlib -datumaro @ git+https://github.com/cvat-ai/datumaro.git@fad7636b79889f0c7b8fb0c3010b894324c4c18a +datumaro @ git+https://github.com/cvat-ai/datumaro.git@08e77b216080555a57e12c01625be8c8201e3131 # via -r cvat/requirements/base.in defusedxml==0.7.1 # via @@ -147,7 +147,7 @@ idna==3.10 # via requests importlib-metadata==8.5.0 # via clickhouse-connect -importlib-resources==6.4.5 +importlib-resources==6.5.2 # via # matplotlib # nibabel @@ -169,7 +169,7 @@ jsonschema==4.17.3 # via drf-spectacular kiwisolver==1.4.7 # via matplotlib -limits==3.14.1 +limits==4.0.0 # via python-logstash-async lxml==5.3.0 # via @@ -308,7 +308,7 @@ rq-scheduler==0.13.1 # via -r cvat/requirements/base.in rsa==4.9 # via google-auth -ruamel-yaml==0.18.8 +ruamel-yaml==0.18.10 # via datumaro ruamel-yaml-clib==0.2.12 # via ruamel-yaml diff --git a/cvat/requirements/production.txt b/cvat/requirements/production.txt index 155d626a6984..c65ede91ad59 100644 --- a/cvat/requirements/production.txt +++ b/cvat/requirements/production.txt @@ -6,7 +6,7 @@ # pip-compile-multi # -r base.txt -anyio==4.7.0 +anyio==4.8.0 # via watchfiles coverage==7.2.3 # via -r cvat/requirements/production.in From b594b1cbc7118d64621a78bb4f937ebd44c451f1 Mon Sep 17 00:00:00 2001 From: Roman Donchenko Date: Wed, 8 Jan 2025 16:48:00 +0200 Subject: [PATCH 29/31] Add a management command to run a configured periodic job immediately (#8909) This can be useful for testing. --- .../management/commands/runperiodicjob.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 cvat/apps/engine/management/commands/runperiodicjob.py diff --git a/cvat/apps/engine/management/commands/runperiodicjob.py b/cvat/apps/engine/management/commands/runperiodicjob.py new file mode 100644 index 000000000000..765f16541cfd --- /dev/null +++ b/cvat/apps/engine/management/commands/runperiodicjob.py @@ -0,0 +1,23 @@ +from argparse import ArgumentParser + +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError +from django.utils.module_loading import import_string + + +class Command(BaseCommand): + help = "Run a configured periodic job immediately" + + def add_arguments(self, parser: ArgumentParser) -> None: + parser.add_argument("job_id", help="ID of the job to run") + + def handle(self, *args, **options): + job_id = options["job_id"] + + for job_definition in settings.PERIODIC_RQ_JOBS: + if job_definition["id"] == job_id: + job_func = import_string(job_definition["func"]) + job_func() + return + + raise CommandError(f"Job with ID {job_id} not found") From f8f40e22b08d62d263e159015b54a35c3656d8cf Mon Sep 17 00:00:00 2001 From: Andrey Zhavoronkov Date: Thu, 9 Jan 2025 19:05:03 +0300 Subject: [PATCH 30/31] Add missed changelog notes (#8917) ### Motivation and context ### How has this been tested? ### Checklist - [ ] I submit my changes into the `develop` branch - [ ] I have created a changelog fragment - [ ] I have updated the documentation accordingly - [ ] I have added tests to cover my changes - [ ] I have linked related issues (see [GitHub docs]( https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)) - [ ] I have increased versions of npm packages if it is necessary ([cvat-canvas](https://github.com/cvat-ai/cvat/tree/develop/cvat-canvas#versioning), [cvat-core](https://github.com/cvat-ai/cvat/tree/develop/cvat-core#versioning), [cvat-data](https://github.com/cvat-ai/cvat/tree/develop/cvat-data#versioning) and [cvat-ui](https://github.com/cvat-ai/cvat/tree/develop/cvat-ui#versioning)) ### License - [x] I submit _my code changes_ under the same [MIT License]( https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. ## Summary by CodeRabbit - **Bug Fixes** - Resolved export and import issues in YOLO format - Fixed export functionality when Train and default datasets are present - Preserved original rotation for YOLO-oriented boxes during export-import - Addressed frame deletion concerns --------- Co-authored-by: Maxim Zhiltsov --- changelog.d/20250109_183137_andrey_update_changelog.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 changelog.d/20250109_183137_andrey_update_changelog.md diff --git a/changelog.d/20250109_183137_andrey_update_changelog.md b/changelog.d/20250109_183137_andrey_update_changelog.md new file mode 100644 index 000000000000..16edda5c4995 --- /dev/null +++ b/changelog.d/20250109_183137_andrey_update_changelog.md @@ -0,0 +1,8 @@ +### Fixed + +- Export to yolo formats if both Train and default dataset are present + () + +- Issue with deleting frames + () + From aaab6655e4938fadcaa7f6eb4c639ae8664abcb1 Mon Sep 17 00:00:00 2001 From: "cvat-bot[bot]" <147643061+cvat-bot[bot]@users.noreply.github.com> Date: Thu, 9 Jan 2025 16:06:30 +0000 Subject: [PATCH 31/31] Prepare release v2.25.0 --- CHANGELOG.md | 32 +++++++++++++++++++ .../20241212_193004_roman_cli_agent.md | 4 --- ...45339_dmitrii.lavrukhin_yolov8_renaming.md | 4 --- ...1229_221630_mzhiltso_empty_is_annotated.md | 6 ---- ...07_163424_dmitrii.lavrukhin_yolo_tracks.md | 4 --- ...mitrii.lavrukhin_yolo_preserve_rotation.md | 4 --- ...20250109_183137_andrey_update_changelog.md | 8 ----- cvat-cli/requirements/base.txt | 2 +- cvat-cli/src/cvat_cli/version.py | 2 +- cvat-sdk/gen/generate.sh | 2 +- cvat/__init__.py | 2 +- cvat/schema.yml | 2 +- docker-compose.yml | 20 ++++++------ helm-chart/values.yaml | 4 +-- 14 files changed, 49 insertions(+), 47 deletions(-) delete mode 100644 changelog.d/20241212_193004_roman_cli_agent.md delete mode 100644 changelog.d/20241224_145339_dmitrii.lavrukhin_yolov8_renaming.md delete mode 100644 changelog.d/20241229_221630_mzhiltso_empty_is_annotated.md delete mode 100644 changelog.d/20250107_163424_dmitrii.lavrukhin_yolo_tracks.md delete mode 100644 changelog.d/20250108_124804_dmitrii.lavrukhin_yolo_preserve_rotation.md delete mode 100644 changelog.d/20250109_183137_andrey_update_changelog.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 31a4aae3db7f..a18a0284f814 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +
+## \[2.25.0\] - 2025-01-09 + +### Added + +- \[CLI\] Added commands for working with native functions + () + +- Ultralytics YOLO formats now support tracks + () + +### Changed + +- YOLOv8 formats renamed to Ultralytics YOLO formats + () + +- The `match_empty_frames` quality setting is changed to `empty_is_annotated`. + The updated option includes any empty frames in the final metrics instead of only + matching empty frames. This makes metrics such as Precision much more representative and useful. + () + +### Fixed + +- Changing rotation after export/import in Ultralytics YOLO Oriented Boxes format + () + +- Export to yolo formats if both Train and default dataset are present + () + +- Issue with deleting frames + () + ## \[2.24.0\] - 2024-12-20 diff --git a/changelog.d/20241212_193004_roman_cli_agent.md b/changelog.d/20241212_193004_roman_cli_agent.md deleted file mode 100644 index f7fd8c0a5be4..000000000000 --- a/changelog.d/20241212_193004_roman_cli_agent.md +++ /dev/null @@ -1,4 +0,0 @@ -### Added - -- \[CLI\] Added commands for working with native functions - () diff --git a/changelog.d/20241224_145339_dmitrii.lavrukhin_yolov8_renaming.md b/changelog.d/20241224_145339_dmitrii.lavrukhin_yolov8_renaming.md deleted file mode 100644 index 957ff9666951..000000000000 --- a/changelog.d/20241224_145339_dmitrii.lavrukhin_yolov8_renaming.md +++ /dev/null @@ -1,4 +0,0 @@ -### Changed - -- YOLOv8 formats renamed to Ultralytics YOLO formats - () diff --git a/changelog.d/20241229_221630_mzhiltso_empty_is_annotated.md b/changelog.d/20241229_221630_mzhiltso_empty_is_annotated.md deleted file mode 100644 index 63746abc86e2..000000000000 --- a/changelog.d/20241229_221630_mzhiltso_empty_is_annotated.md +++ /dev/null @@ -1,6 +0,0 @@ -### Changed - -- The `match_empty_frames` quality setting is changed to `empty_is_annotated`. - The updated option includes any empty frames in the final metrics instead of only - matching empty frames. This makes metrics such as Precision much more representative and useful. - () diff --git a/changelog.d/20250107_163424_dmitrii.lavrukhin_yolo_tracks.md b/changelog.d/20250107_163424_dmitrii.lavrukhin_yolo_tracks.md deleted file mode 100644 index 68c2d85fdf1c..000000000000 --- a/changelog.d/20250107_163424_dmitrii.lavrukhin_yolo_tracks.md +++ /dev/null @@ -1,4 +0,0 @@ -### Added - -- Ultralytics YOLO formats now support tracks - () diff --git a/changelog.d/20250108_124804_dmitrii.lavrukhin_yolo_preserve_rotation.md b/changelog.d/20250108_124804_dmitrii.lavrukhin_yolo_preserve_rotation.md deleted file mode 100644 index 57eafc77f2b7..000000000000 --- a/changelog.d/20250108_124804_dmitrii.lavrukhin_yolo_preserve_rotation.md +++ /dev/null @@ -1,4 +0,0 @@ -### Fixed - -- Changing rotation after export/import in Ultralytics YOLO Oriented Boxes format - () diff --git a/changelog.d/20250109_183137_andrey_update_changelog.md b/changelog.d/20250109_183137_andrey_update_changelog.md deleted file mode 100644 index 16edda5c4995..000000000000 --- a/changelog.d/20250109_183137_andrey_update_changelog.md +++ /dev/null @@ -1,8 +0,0 @@ -### Fixed - -- Export to yolo formats if both Train and default dataset are present - () - -- Issue with deleting frames - () - diff --git a/cvat-cli/requirements/base.txt b/cvat-cli/requirements/base.txt index a53fd13b472e..a40cefb84e01 100644 --- a/cvat-cli/requirements/base.txt +++ b/cvat-cli/requirements/base.txt @@ -1,4 +1,4 @@ -cvat-sdk==2.24.1 +cvat-sdk==2.25.0 attrs>=24.2.0 Pillow>=10.3.0 diff --git a/cvat-cli/src/cvat_cli/version.py b/cvat-cli/src/cvat_cli/version.py index c176a6b233ec..3b01a28f6f60 100644 --- a/cvat-cli/src/cvat_cli/version.py +++ b/cvat-cli/src/cvat_cli/version.py @@ -1 +1 @@ -VERSION = "2.24.1" +VERSION = "2.25.0" diff --git a/cvat-sdk/gen/generate.sh b/cvat-sdk/gen/generate.sh index 939ac9d65b44..855a7d71c4f0 100755 --- a/cvat-sdk/gen/generate.sh +++ b/cvat-sdk/gen/generate.sh @@ -8,7 +8,7 @@ set -e GENERATOR_VERSION="v6.0.1" -VERSION="2.24.1" +VERSION="2.25.0" LIB_NAME="cvat_sdk" LAYER1_LIB_NAME="${LIB_NAME}/api_client" DST_DIR="$(cd "$(dirname -- "$0")/.." && pwd)" diff --git a/cvat/__init__.py b/cvat/__init__.py index cd11fa1758cc..f9c5ae2659e5 100644 --- a/cvat/__init__.py +++ b/cvat/__init__.py @@ -4,6 +4,6 @@ from cvat.utils.version import get_version -VERSION = (2, 24, 1, "alpha", 0) +VERSION = (2, 25, 0, "final", 0) __version__ = get_version(VERSION) diff --git a/cvat/schema.yml b/cvat/schema.yml index 3da1b323a14b..4e0b3a687677 100644 --- a/cvat/schema.yml +++ b/cvat/schema.yml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: CVAT REST API - version: 2.24.1 + version: 2.25.0 description: REST API for Computer Vision Annotation Tool (CVAT) termsOfService: https://www.google.com/policies/terms/ contact: diff --git a/docker-compose.yml b/docker-compose.yml index c13cb5bab74f..5e591b29e0ec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,7 +81,7 @@ services: cvat_server: container_name: cvat_server - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.25.0} restart: always depends_on: <<: *backend-deps @@ -115,7 +115,7 @@ services: cvat_utils: container_name: cvat_utils - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.25.0} restart: always depends_on: *backend-deps environment: @@ -132,7 +132,7 @@ services: cvat_worker_import: container_name: cvat_worker_import - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.25.0} restart: always depends_on: *backend-deps environment: @@ -148,7 +148,7 @@ services: cvat_worker_export: container_name: cvat_worker_export - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.25.0} restart: always depends_on: *backend-deps environment: @@ -164,7 +164,7 @@ services: cvat_worker_annotation: container_name: cvat_worker_annotation - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.25.0} restart: always depends_on: *backend-deps environment: @@ -180,7 +180,7 @@ services: cvat_worker_webhooks: container_name: cvat_worker_webhooks - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.25.0} restart: always depends_on: *backend-deps environment: @@ -196,7 +196,7 @@ services: cvat_worker_quality_reports: container_name: cvat_worker_quality_reports - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.25.0} restart: always depends_on: *backend-deps environment: @@ -212,7 +212,7 @@ services: cvat_worker_analytics_reports: container_name: cvat_worker_analytics_reports - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.25.0} restart: always depends_on: *backend-deps environment: @@ -228,7 +228,7 @@ services: cvat_worker_chunks: container_name: cvat_worker_chunks - image: cvat/server:${CVAT_VERSION:-dev} + image: cvat/server:${CVAT_VERSION:-v2.25.0} restart: always depends_on: *backend-deps environment: @@ -244,7 +244,7 @@ services: cvat_ui: container_name: cvat_ui - image: cvat/ui:${CVAT_VERSION:-dev} + image: cvat/ui:${CVAT_VERSION:-v2.25.0} restart: always depends_on: - cvat_server diff --git a/helm-chart/values.yaml b/helm-chart/values.yaml index 782840f2ed28..041d088ba214 100644 --- a/helm-chart/values.yaml +++ b/helm-chart/values.yaml @@ -139,7 +139,7 @@ cvat: additionalVolumeMounts: [] replicas: 1 image: cvat/server - tag: dev + tag: v2.25.0 imagePullPolicy: Always permissionFix: enabled: true @@ -162,7 +162,7 @@ cvat: frontend: replicas: 1 image: cvat/ui - tag: dev + tag: v2.25.0 imagePullPolicy: Always labels: {} # test: test