chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:23 +08:00
commit 10df941303
2913 changed files with 455804 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
[run]
branch = true
parallel = true
sigterm = true
concurrency=thread
source =
cvat/apps/
cvat-sdk/
cvat-cli/
utils/dataset_manifest
omit =
cvat/settings/*
*/tests/*
*/test_*
*/_test_*
*/migrations/*
[report]
exclude_lines =
# Have to re-enable the standard pragma
pragma: no cover
# Don't complain about missing debug-only code:
def __repr__
if\s+[\w\.()]+\.isEnabledFor\(log\.DEBUG\):
# Don't complain if tests don't hit defensive assertion code:
raise AssertionError
raise NotImplementedError
# Don't complain if non-runnable code isn't run:
if 0:
if __name__ == .__main__.:
# don't fail on the code that can be found
ignore_errors = true
skip_empty = true
+204
View File
@@ -0,0 +1,204 @@
<!--
Copyright (C) 2021-2022 Intel Corporation
SPDX-License-Identifier: MIT
-->
# Testing infrastructure for REST API v2.0
## Motivation
It was very annoying to support the testing infrastructure with FakeRedis,
unittest framework, hardcoded data in the code.
[DRF testing](https://www.django-rest-framework.org/api-guide/testing/)
approach works well only for a single server. But if you have a number
of microservices, it becomes really hard to implement reliable tests.
For example, CVAT consists of server itself, OPA, Redis, DB, Nuclio services.
Also it is worth to have a real instance with real data inside and tests
the server calling REST API directly (as it done by users).
## How to run?
**Initial steps**
1. On Debian/Ubuntu, make sure that your `$USER` is in `docker` group:
```shell
sudo usermod -aG docker $USER
```
1. Follow [this guide](../../site/content/en/docs/api_sdk/sdk/developer-guide.md) to prepare
`cvat-sdk` and `cvat-cli` source code
1. Install all necessary requirements before running REST API tests:
```shell
pip install -r ./tests/python/requirements.txt
```
1. Stop any other CVAT containers which you run previously. They keep ports
which are used by containers for the testing system.
**Running tests**
- Run all REST API tests:
```shell
pytest ./tests/python
```
This command will automatically start all necessary docker containers.
See the [contributing guide](../../site/content/en/docs/contributing/running-tests.md)
to get more information about tests running.
## How to upgrade testing assets?
When you have a new use case which cannot be expressed using objects already
available in the system like comments, users, issues, please use the following
procedure to add them:
1. Run a clean CVAT instance and restore DB and data volume
```console
pytest tests/python/ --start-services
```
1. Add new objects (e.g. issues, comments, tasks, projects)
1. Backup DB and data volume using commands below
1. Don't forget to dump new objects into corresponding json files inside
assets directory
1. Commit cvat_data.tar.bz2 and data.json into git. Be sure that they are
small enough: ~300K together.
It is recommended to use dummy and tiny images. You can generate them using
Pillow library. See a sample code below:
```python
from PIL import Image
from PIL.ImageColor import colormap, getrgb
from random import randint
for i, color in enumerate(colormap):
size = (randint(100, 1000), randint(100, 1000))
img = Image.new('RGB', size, getrgb(color))
img.save(f'{i}.png')
```
## How to backup DB and data volume?
To backup DB and data volume, please use commands below.
```console
cd tests/python
python shared/utils/dump_test_db.py
docker exec test_cvat_server_1 tar --exclude "/home/django/data/cache" -cjv /home/django/data > shared/assets/cvat_db/cvat_data.tar.bz2
```
## How to update *.json files in the assets directory?
If you have updated the test database and want to update the assets/*.json
files as well, run the appropriate script:
```
cd tests/python
pytest ./ --start-services
python shared/utils/dump_objects.py
```
## How to restore DB and data volume?
To restore DB and data volume, please use commands below.
```console
cat shared/assets/cvat_db/data.json | docker exec -i test_cvat_server_1 python manage.py loaddata_sorted
cat shared/assets/cvat_db/cvat_data.tar.bz2 | docker exec -i test_cvat_server_1 tar --strip 3 -C /home/django/data/ -xj
```
## Assets directory structure
Assets directory has two parts:
- `cvat_db` directory --- this directory contains all necessary files for
successful restoring of test db
- `cvat_data.tar.bz2` --- archive with data volumes;
- `data.json` --- file required for DB restoring.
Contains all information about test db;
- `restore.sql` --- SQL script for creating copy of database and
killing connection for `cvat` database.
Script should be run with variable declaration:
```
# create database <new> with template <existing>
psql -U root -d postgres -v from=<existing> -v to=<new> restore.sql
```
- `*.json` files --- these file contains all necessary data for getting
expected results from HTTP responses
## FAQ
1. How to merge two DB dumps?
In common case it should be easy just to merge two JSON files.
But in the case when a simple merge fails, you have to first merge
the branches, then re-create the changes that you made.
1. How to upgrade cvat_data.tar.bz2 and data.json?
After every commit which changes the layout of DB and data directory it is
possible to break these files. But failed tests should be a clear indicator
of that.
1. Should we use only json files to re-create all objects in the testing
system?
Construction of some objects can be complex and takes time (backup
and restore should be much faster). Construction of objects in UI is more
intuitive.
1. How we solve the problem of dependent tests?
Since some tests change the database, these tests may be dependent on each
other, so in current implementation we avoid such problem by restoring
the database after each test function (see `conftest.py`)
1. Which user should be selected to create new resources in test DB?
If for your test it's no matter what user should send a request,
then better to choose `admin1` user for creating new resource.
## Troubleshooting
1. If your tests was failed due to date field incompatibility and you have
error message like this:
```
assert {'values_chan...34.908528Z'}}} == {}
E Left contains 1 more item:
E {'values_changed': {"root['results'][0]['updated_date']": {'new_value': '2022-03-05T08:52:34.908000Z',
E 'old_value': '2022-03-05T08:52:34.908528Z'}}}
E Use -v to get the full diff
```
Just dump JSON assets with:
```
python3 tests/python/shared/utils/dump_objects.py
```
1. If your test infrastructure has been corrupted and you have errors during db restoring.
You should to create (or recreate) `cvat` database:
```
docker exec test_cvat_db_1 dropdb --if-exists cvat
docker exec test_cvat_db_1 createdb cvat
docker exec test_cvat_server_1 python manage.py migrate
```
1. Perform migrate when some relation does not exists. Example of error message:
```
django.db.utils.ProgrammingError: Problem installing fixture '/data.json': Could not load admin.LogEntry(pk=1): relation "django_admin_log" does not exist`
```
Solution:
```
docker exec test_cvat_server_1 python manage.py migrate
```
1. If for some reason you need to recreate cvat database, but using `dropdb`
you have error message:
```
ERROR: database "cvat" is being accessed by other users
DETAIL: There are 1 other session(s) using the database.
```
In this case you should terminate all existent connections for cvat database,
you can perform it with command:
```
docker exec test_cvat_db_1 psql -U root -d postgres -v from=cvat_server -v to=test_db -f restore.sql
```
View File
+22
View File
@@ -0,0 +1,22 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import cvat_sdk.auto_annotation as cvataa
import cvat_sdk.models as models
import PIL.Image
spec = cvataa.DetectionFunctionSpec(
labels=[
cvataa.label_spec("car", 0),
],
)
def detect(
context: cvataa.DetectionFunctionContext, image: PIL.Image.Image
) -> list[models.LabeledShapeRequest]:
if context.conv_mask_to_poly:
return [cvataa.polygon(0, [0, 0, 0, 1, 1, 1])]
else:
return [cvataa.mask(0, [1, 0, 0, 0, 0])]
@@ -0,0 +1,21 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import cvat_sdk.auto_annotation as cvataa
import cvat_sdk.models as models
import PIL.Image
spec = cvataa.DetectionFunctionSpec(
labels=[
cvataa.label_spec("car", 0),
],
)
def detect(
context: cvataa.DetectionFunctionContext, image: PIL.Image.Image
) -> list[models.LabeledShapeRequest]:
return [
cvataa.rectangle(0, [context.conf_threshold, 1, 1, 1]),
]
+6
View File
@@ -0,0 +1,6 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
# Force execution of fixture definitions
from sdk.fixtures import * # pylint: disable=wildcard-import
+21
View File
@@ -0,0 +1,21 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import cvat_sdk.auto_annotation as cvataa
import cvat_sdk.models as models
import PIL.Image
spec = cvataa.DetectionFunctionSpec(
labels=[
cvataa.label_spec("car", 0),
],
)
def detect(
context: cvataa.DetectionFunctionContext, image: PIL.Image.Image
) -> list[models.LabeledShapeRequest]:
return [
cvataa.rectangle(0, [1, 2, 3, 4]),
]
@@ -0,0 +1,31 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
from types import SimpleNamespace as namespace
import cvat_sdk.auto_annotation as cvataa
import cvat_sdk.models as models
import PIL.Image
def create(s: str, i: int, f: float, b: bool) -> cvataa.DetectionFunction:
assert s == "string"
assert i == 123
assert f == 5.5
assert b is False
spec = cvataa.DetectionFunctionSpec(
labels=[
cvataa.label_spec("car", 0),
],
)
def detect(
context: cvataa.DetectionFunctionContext, image: PIL.Image.Image
) -> list[models.LabeledShapeRequest]:
return [
cvataa.rectangle(0, [1, 2, 3, 4]),
]
return namespace(spec=spec, detect=detect)
+240
View File
@@ -0,0 +1,240 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import os
from datetime import timedelta
from io import BytesIO
from pathlib import Path
from unittest import mock
import packaging.version as pv
import pytest
from cvat_cli._internal.agent import (
_Event,
_NewReconnectionDelay,
_parse_event_stream,
_TaskCacheLimiter,
)
from cvat_sdk import Client
from cvat_sdk.api_client import models
from cvat_sdk.core.proxies.tasks import ResourceType
from sdk.util import https_reverse_proxy
from .util import TestCliBase, generate_images, run_cli
class TestCliMisc(TestCliBase):
def test_can_warn_on_mismatching_server_version(self, monkeypatch, caplog):
def mocked_version(_):
return pv.Version("0")
# We don't actually run a separate process in the tests here, so it works
monkeypatch.setattr(Client, "get_server_version", mocked_version)
self.run_cli("task", "ls")
assert "Server version '0' is not compatible with SDK version" in caplog.text
@pytest.mark.parametrize("verify", [True, False])
def test_can_control_ssl_verification_with_arg(self, verify: bool):
with https_reverse_proxy() as proxy_url:
if verify:
insecure_args = []
else:
insecure_args = ["--insecure"]
run_cli(
self,
f"--auth={self.user}:{self.password}",
f"--server-host={proxy_url}",
*insecure_args,
"task",
"ls",
expected_code=1 if verify else 0,
)
stdout = self.stdout.getvalue()
if not verify:
for line in stdout.splitlines():
int(line)
def test_can_control_organization_context(self):
org = "cli-test-org"
self.client.organizations.create(models.OrganizationWriteRequest(org))
files = generate_images(self.tmp_path, 1)
stdout = self.run_cli(
"task",
"create",
"personal_task",
ResourceType.LOCAL.name,
*map(os.fspath, files),
"--completion_verification_period=0.01",
organization="",
)
personal_task_id = int(stdout.split()[-1])
stdout = self.run_cli(
"task",
"create",
"org_task",
ResourceType.LOCAL.name,
*map(os.fspath, files),
"--completion_verification_period=0.01",
organization=org,
)
org_task_id = int(stdout.split()[-1])
personal_task_ids = list(map(int, self.run_cli("task", "ls", organization="").split()))
assert personal_task_id in personal_task_ids
assert org_task_id not in personal_task_ids
org_task_ids = list(map(int, self.run_cli("task", "ls", organization=org).split()))
assert personal_task_id not in org_task_ids
assert org_task_id in org_task_ids
all_task_ids = list(map(int, self.run_cli("task", "ls").split()))
assert personal_task_id in all_task_ids
assert org_task_id in all_task_ids
def test_can_use_access_token_env_variable(
self, monkeypatch: pytest.MonkeyPatch, access_tokens
):
token = next(t for t in access_tokens)["private_key"]
from cvat_sdk.api_client.rest import RESTClientObject
original_request = RESTClientObject.request
calls = 0
def patched_request(self, *args, **kwargs):
nonlocal calls
calls += 1
assert kwargs["headers"].get("Authorization") == f"Bearer {token}"
return original_request(self, *args, **kwargs)
monkeypatch.setenv("CVAT_ACCESS_TOKEN", token)
monkeypatch.setattr(RESTClientObject, "request", patched_request)
self.run_cli("task", "ls", authenticate=False)
assert calls
def test_can_use_current_user_env_variable(self, monkeypatch: pytest.MonkeyPatch):
# set all user env vars supported by getuser()
for env_var in ("LOGNAME", "USER", "LNAME", "USERNAME"):
monkeypatch.setenv(env_var, self.user)
from getpass import getuser as original_getuser
from cvat_sdk.core.auth import default_auth_factory
with (
mock.patch(
"cvat_sdk.core.auth.default_auth_factory", wraps=default_auth_factory
) as mock_auth_factory,
mock.patch("getpass.getuser", wraps=original_getuser) as mock_getuser,
mock.patch("getpass.getpass", return_value=self.password) as mock_getpass,
):
self.run_cli("task", "ls", authenticate=False)
mock_auth_factory.assert_called_once()
mock_getuser.assert_called()
mock_getpass.assert_called_once()
def test_can_use_pass_env_variable(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("PASS", self.password)
from cvat_sdk.core.auth import get_auth_factory
with (
mock.patch(
"cvat_sdk.core.auth.get_auth_factory", wraps=get_auth_factory
) as mock_auth_factory,
mock.patch("getpass.getpass") as mock_getpass,
):
self.run_cli(f"--auth={self.user}", "task", "ls", authenticate=False)
mock_auth_factory.assert_called_once()
mock_getpass.assert_not_called()
@pytest.mark.parametrize(
["lines", "messages"],
[
# empty
([], []),
([""], [_Event("", "")]),
# event only
(["event: test", ""], [_Event("test", "")]),
(["event: foo", "event: bar", ""], [_Event("bar", "")]),
# data only
(["data: test", ""], [_Event("", "test")]),
(["data: foo", "data: bar", ""], [_Event("", "foo\nbar")]),
# event and data
(["event: test", "data: foo", "data: bar", ""], [_Event("test", "foo\nbar")]),
(["data: foo", "event: test", "data: bar", ""], [_Event("test", "foo\nbar")]),
(["data: foo", "data: bar", "event: test", ""], [_Event("test", "foo\nbar")]),
# fields without values
(["event: test", "event", ""], [_Event("", "")]),
(["data: test", "data", ""], [_Event("", "test\n")]),
# incomplete event
(["event: test", "data: foo"], []),
# multiple events
(
["event: test1", "data: foo", "", "event: test2", "data: bar", ""],
[_Event("test1", "foo"), _Event("test2", "bar")],
),
# comments
([":"], []),
([":1", "event: test", ":2", "data: foo", ":3", ""], [_Event("test", "foo")]),
# retry
(["retry: 1234"], [_NewReconnectionDelay(timedelta(milliseconds=1234))]),
(["retry", "retry:", "retry: a"], []),
# no space
(["event:test", "data:foo", ""], [_Event("test", "foo")]),
# two spaces
(["event: test", "data: foo", ""], [_Event(" test", " foo")]),
# carriage return
(["event: test\r", "data: foo\r", "\r"], [_Event("test", "foo")]),
],
)
def test_parse_event_stream(lines, messages):
stream = BytesIO(b"".join(line.encode() + b"\n" for line in lines))
assert list(_parse_event_stream(stream)) == messages
def test_task_cache_limiter_keeps_last_10_tasks(
tmp_path: Path,
fxt_login: tuple[Client, str],
fxt_logger,
):
client = fxt_login[0]
client.logger = fxt_logger[0]
client.config.cache_dir = tmp_path / "cache"
limiter = _TaskCacheLimiter(client)
for task_id in range(1, 13):
limiter._cache_manager.task_dir(task_id).mkdir(parents=True)
with limiter.using_cache_for_task(task_id):
pass
if task_id <= 10:
for cached_task_id in range(1, task_id + 1):
assert limiter._cache_manager.task_dir(cached_task_id).exists()
elif task_id == 11:
assert not limiter._cache_manager.task_dir(1).exists()
for cached_task_id in range(2, 12):
assert limiter._cache_manager.task_dir(cached_task_id).exists()
elif task_id == 12:
assert not limiter._cache_manager.task_dir(1).exists()
assert not limiter._cache_manager.task_dir(2).exists()
for cached_task_id in range(3, 13):
assert limiter._cache_manager.task_dir(cached_task_id).exists()
+196
View File
@@ -0,0 +1,196 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import json
import os
import zipfile
import pytest
from cvat_sdk.api_client import exceptions
from cvat_sdk.core.proxies.projects import Project
from cvat_sdk.core.proxies.tasks import ResourceType
from .util import TestCliBase, generate_images
class TestCliProjects(TestCliBase):
@pytest.fixture
def fxt_new_project(self):
project = self.client.projects.create(
spec={
"name": "test_project",
"labels": [{"name": "car"}, {"name": "person"}],
},
)
return project
@pytest.fixture
def fxt_project_with_task(self, fxt_new_project: Project):
files = generate_images(self.tmp_path, 5)
self.client.tasks.create_from_data(
spec={
"name": "test_task",
"project_id": fxt_new_project.id,
},
resource_type=ResourceType.LOCAL,
resources=files,
)
fxt_new_project.fetch()
return fxt_new_project
@pytest.fixture
def fxt_backup_file(self, fxt_project_with_task: Project):
backup_path = self.tmp_path / "backup.zip"
fxt_project_with_task.download_backup(backup_path)
yield backup_path
def test_can_create_project(self):
stdout = self.run_cli(
"project",
"create",
"new_project",
"--labels",
json.dumps([{"name": "car"}, {"name": "person"}]),
"--bug_tracker",
"https://bugs.example/",
)
project_id = int(stdout.rstrip("\n"))
created_project = self.client.projects.retrieve(project_id)
assert created_project.name == "new_project"
assert created_project.bug_tracker == "https://bugs.example/"
assert {label.name for label in created_project.get_labels()} == {"car", "person"}
def test_can_create_project_from_dataset(self, fxt_coco_dataset):
stdout = self.run_cli(
"project",
"create",
"new_project",
"--dataset_path",
os.fspath(fxt_coco_dataset),
"--dataset_format",
"COCO 1.0",
)
project_id = int(stdout.rstrip("\n"))
created_project = self.client.projects.retrieve(project_id)
assert created_project.name == "new_project"
assert {label.name for label in created_project.get_labels()} == {"car", "person"}
assert created_project.tasks.count == 1
def test_can_list_projects_in_simple_format(self, fxt_new_project: Project):
output = self.run_cli("project", "ls")
results = output.split("\n")
assert any(str(fxt_new_project.id) in r for r in results)
def test_can_list_project_in_json_format(self, fxt_new_project: Project):
output = self.run_cli("project", "ls", "--json")
results = json.loads(output)
assert any(r["id"] == fxt_new_project.id for r in results)
def test_can_delete_project(self, fxt_new_project: Project):
self.run_cli("project", "delete", str(fxt_new_project.id))
with pytest.raises(exceptions.NotFoundException):
fxt_new_project.fetch()
def test_can_download_project_backup(self, fxt_project_with_task: Project):
filename = self.tmp_path / f"project_{fxt_project_with_task.id}-cvat.zip"
self.run_cli(
"project",
"backup",
str(fxt_project_with_task.id),
str(filename),
"--completion_verification_period",
"0.01",
)
assert 0 < filename.stat().st_size
with zipfile.ZipFile(filename) as backup_zip:
assert "project.json" in backup_zip.namelist()
def test_can_download_project_backup_with_server_filename(self, fxt_project_with_task: Project):
output_dir = str(self.tmp_path / "save_dir") + os.path.sep
self.run_cli(
"project",
"backup",
str(fxt_project_with_task.id),
output_dir,
"--completion_verification_period",
"0.01",
)
output_dir_files = os.listdir(output_dir)
assert len(output_dir_files) == 1
assert os.stat(output_dir + output_dir_files[0]).st_size > 0
def test_can_create_project_from_backup(self, fxt_project_with_task: Project, fxt_backup_file):
stdout = self.run_cli("project", "create-from-backup", str(fxt_backup_file))
project_id = int(stdout.rstrip("\n"))
assert project_id
assert project_id != fxt_project_with_task.id
restored_project = self.client.projects.retrieve(project_id)
restored_tasks = restored_project.get_tasks()
assert len(restored_tasks) == 1
assert restored_tasks[0].size == fxt_project_with_task.get_tasks()[0].size
def test_can_download_project_annotations(self, fxt_project_with_task: Project):
filename = self.tmp_path / f"project_{fxt_project_with_task.id}-cvat.zip"
self.run_cli(
"project",
"export-dataset",
str(fxt_project_with_task.id),
str(filename),
"--format",
"CVAT for images 1.1",
"--with-images",
"no",
"--completion_verification_period",
"0.01",
)
assert 0 < filename.stat().st_size
def test_can_download_project_annotations_with_server_filename(
self, fxt_project_with_task: Project
):
output_dir = str(self.tmp_path / "save_dir") + os.path.sep
self.run_cli(
"project",
"export-dataset",
str(fxt_project_with_task.id),
output_dir,
"--format",
"CVAT for images 1.1",
"--with-images",
"yes",
"--completion_verification_period",
"0.01",
)
output_dir_files = os.listdir(output_dir)
assert len(output_dir_files) == 1
assert os.stat(output_dir + output_dir_files[0]).st_size > 0
def test_can_upload_project_annotations(self, fxt_new_project: Project, fxt_coco_dataset):
self.run_cli(
"project",
"import-dataset",
str(fxt_new_project.id),
os.fspath(fxt_coco_dataset),
"--format",
"COCO 1.0",
)
fxt_new_project.fetch()
assert fxt_new_project.tasks.count == 1
+350
View File
@@ -0,0 +1,350 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import json
import os
import zipfile
from pathlib import Path
import pytest
from cvat_sdk.api_client import exceptions
from cvat_sdk.core.proxies.tasks import ResourceType, Task
from PIL import Image
from sdk.util import generate_coco_json
from shared.utils.helpers import generate_image_file
from .util import TestCliBase, generate_images
class TestCliTasks(TestCliBase):
@pytest.fixture
def fxt_image_file(self):
img_path = self.tmp_path / "img_0.png"
with img_path.open("wb") as f:
f.write(generate_image_file(filename=str(img_path)).getvalue())
return img_path
@pytest.fixture
def fxt_coco_file(self, fxt_image_file: Path):
img_filename = fxt_image_file
img_size = Image.open(img_filename).size
ann_filename = self.tmp_path / "coco.json"
generate_coco_json(ann_filename, img_info=(img_filename, *img_size))
yield ann_filename
@pytest.fixture
def fxt_backup_file(self, fxt_new_task: Task, fxt_coco_file: str):
backup_path = self.tmp_path / "backup.zip"
fxt_new_task.import_annotations("COCO 1.0", filename=fxt_coco_file)
fxt_new_task.download_backup(backup_path)
yield backup_path
@pytest.fixture
def fxt_new_task(self):
files = generate_images(self.tmp_path, 5)
task = self.client.tasks.create_from_data(
spec={
"name": "test_task",
"labels": [{"name": "car"}, {"name": "person"}],
},
resource_type=ResourceType.LOCAL,
resources=files,
)
return task
def test_can_create_task_from_local_images(self):
files = generate_images(self.tmp_path, 5)
stdout = self.run_cli(
"task",
"create",
"test_task",
ResourceType.LOCAL.name,
*map(os.fspath, files),
"--labels",
json.dumps([{"name": "car"}, {"name": "person"}]),
"--completion_verification_period",
"0.01",
)
task_id = int(stdout.rstrip("\n"))
assert self.client.tasks.retrieve(task_id).size == 5
def test_can_create_task_from_local_images_with_parameters(self):
# Checks for regressions of <https://github.com/cvat-ai/cvat/issues/4962>
files = generate_images(self.tmp_path, 7)
files.sort(reverse=True)
frame_step = 3
stdout = self.run_cli(
"task",
"create",
"test_task",
ResourceType.LOCAL.name,
*map(os.fspath, files),
"--labels",
json.dumps([{"name": "car"}, {"name": "person"}]),
"--completion_verification_period",
"0.01",
"--sorting-method",
"predefined",
"--frame_step",
str(frame_step),
"--bug_tracker",
"http://localhost/bug",
)
task_id = int(stdout.rstrip("\n"))
task = self.client.tasks.retrieve(task_id)
frames = task.get_frames_info()
assert [f.name for f in frames] == [
f.name for i, f in enumerate(files) if i % frame_step == 0
]
assert task.get_meta().frame_filter == f"step={frame_step}"
assert task.bug_tracker == "http://localhost/bug"
def test_can_list_tasks_in_simple_format(self, fxt_new_task: Task):
output = self.run_cli("task", "ls")
results = output.split("\n")
assert any(str(fxt_new_task.id) in r for r in results)
def test_can_list_tasks_in_json_format(self, fxt_new_task: Task):
output = self.run_cli("task", "ls", "--json")
results = json.loads(output)
assert any(r["id"] == fxt_new_task.id for r in results)
def test_can_delete_task(self, fxt_new_task: Task):
self.run_cli("task", "delete", str(fxt_new_task.id))
with pytest.raises(exceptions.NotFoundException):
fxt_new_task.fetch()
def test_can_download_task_annotations(self, fxt_new_task: Task):
filename = self.tmp_path / "task_{fxt_new_task.id}-cvat.zip"
self.run_cli(
"task",
"export-dataset",
str(fxt_new_task.id),
str(filename),
"--format",
"CVAT for images 1.1",
"--with-images",
"no",
"--completion_verification_period",
"0.01",
)
assert 0 < filename.stat().st_size
def test_can_download_task_annotations_with_server_filename(self, fxt_new_task: Task):
output_dir = str(self.tmp_path / "save_dir") + os.path.sep
self.run_cli(
"task",
"export-dataset",
str(fxt_new_task.id),
output_dir,
"--format",
"CVAT for images 1.1",
"--with-images",
"no",
"--completion_verification_period",
"0.01",
)
output_dir_files = os.listdir(output_dir)
assert len(output_dir_files) == 1
assert os.stat(output_dir + output_dir_files[0]).st_size > 0
def test_can_download_task_annotations_to_current_directory(
self, fxt_new_task: Task, monkeypatch: pytest.MonkeyPatch
):
output_dir = self.tmp_path / "current_dir"
output_dir.mkdir()
monkeypatch.chdir(output_dir)
self.run_cli(
"task",
"export-dataset",
str(fxt_new_task.id),
"--format",
"CVAT for images 1.1",
"--with-images",
"no",
"--completion_verification_period",
"0.01",
)
output_dir_files = list(output_dir.iterdir())
assert len(output_dir_files) == 1
assert output_dir_files[0].stat().st_size > 0
def test_can_download_task_backup(self, fxt_new_task: Task):
filename = self.tmp_path / "task_{fxt_new_task.id}-cvat.zip"
self.run_cli(
"task",
"backup",
str(fxt_new_task.id),
str(filename),
"--completion_verification_period",
"0.01",
)
assert 0 < filename.stat().st_size
with zipfile.ZipFile(filename) as backup_zip:
assert "task.json" in backup_zip.namelist()
def test_can_download_task_backup_with_server_filename(self, fxt_new_task: Task):
output_dir = str(self.tmp_path / "save_dir") + os.path.sep
self.run_cli(
"task",
"backup",
str(fxt_new_task.id),
output_dir,
"--completion_verification_period",
"0.01",
)
output_dir_files = os.listdir(output_dir)
assert len(output_dir_files) == 1
assert os.stat(output_dir + output_dir_files[0]).st_size > 0
@pytest.mark.parametrize("quality", ("compressed", "original"))
def test_can_download_task_frames(self, fxt_new_task: Task, quality: str):
out_dir = str(self.tmp_path / "downloads")
self.run_cli(
"task",
"frames",
str(fxt_new_task.id),
"0",
"1",
"--outdir",
out_dir,
"--quality",
quality,
)
assert set(os.listdir(out_dir)) == {
"task_{}_frame_{:06d}.jpg".format(fxt_new_task.id, i) for i in range(2)
}
def test_can_upload_annotations(self, fxt_new_task: Task, fxt_coco_file: Path):
self.run_cli(
"task",
"import-dataset",
str(fxt_new_task.id),
str(fxt_coco_file),
"--format",
"COCO 1.0",
)
def test_can_create_from_backup(self, fxt_new_task: Task, fxt_backup_file: Path):
stdout = self.run_cli("task", "create-from-backup", str(fxt_backup_file))
task_id = int(stdout.rstrip("\n"))
assert task_id
assert task_id != fxt_new_task.id
assert self.client.tasks.retrieve(task_id).size == fxt_new_task.size
def test_auto_annotate_with_module(self, fxt_new_task: Task):
annotations = fxt_new_task.get_annotations()
assert not annotations.shapes
self.run_cli(
"task",
"auto-annotate",
str(fxt_new_task.id),
f"--function-module={__package__}.example_function",
)
annotations = fxt_new_task.get_annotations()
assert annotations.shapes
def test_auto_annotate_with_file(self, fxt_new_task: Task):
annotations = fxt_new_task.get_annotations()
assert not annotations.shapes
self.run_cli(
"task",
"auto-annotate",
str(fxt_new_task.id),
f"--function-file={Path(__file__).with_name('example_function.py')}",
)
annotations = fxt_new_task.get_annotations()
assert annotations.shapes
def test_auto_annotate_with_parameters(self, fxt_new_task: Task):
annotations = fxt_new_task.get_annotations()
assert not annotations.shapes
self.run_cli(
"task",
"auto-annotate",
str(fxt_new_task.id),
f"--function-module={__package__}.example_parameterized_function",
"-ps=str:string",
"-pi=int:123",
"-pf=float:5.5",
"-pb=bool:false",
)
annotations = fxt_new_task.get_annotations()
assert annotations.shapes
def test_auto_annotate_with_threshold(self, fxt_new_task: Task):
annotations = fxt_new_task.get_annotations()
assert not annotations.shapes
self.run_cli(
"task",
"auto-annotate",
str(fxt_new_task.id),
f"--function-module={__package__}.conf_threshold_function",
"--conf-threshold=0.75",
)
annotations = fxt_new_task.get_annotations()
assert annotations.shapes[0].points[0] == 0.75 # python:S1244 NOSONAR
def test_auto_annotate_with_cmtp(self, fxt_new_task: Task):
self.run_cli(
"task",
"auto-annotate",
str(fxt_new_task.id),
f"--function-module={__package__}.cmtp_function",
"--clear-existing",
)
annotations = fxt_new_task.get_annotations()
assert annotations.shapes[0].type.value == "mask"
self.run_cli(
"task",
"auto-annotate",
str(fxt_new_task.id),
f"--function-module={__package__}.cmtp_function",
"--clear-existing",
"--conv-mask-to-poly",
)
annotations = fxt_new_task.get_annotations()
assert annotations.shapes[0].type.value == "polygon"
def test_legacy_alias(self, caplog):
# All legacy aliases are implemented the same way;
# no need to test every single one.
self.run_cli("ls")
assert "deprecated" in caplog.text
assert "task ls" in caplog.text
+94
View File
@@ -0,0 +1,94 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import io
import unittest
from pathlib import Path
from typing import Any
import pytest
from cvat_sdk import make_client
from shared.utils.config import BASE_URL, USER_PASS
from shared.utils.helpers import generate_image_file
def run_cli(
test: unittest.TestCase | Any,
*args: str,
expected_code: int = 0,
) -> None:
from cvat_cli.__main__ import main
if isinstance(test, unittest.TestCase):
# Unittest
test.assertEqual(expected_code, main(args), str(args))
else:
# Pytest case
assert expected_code == main(args)
def generate_images(dst_dir: Path, count: int) -> list[Path]:
filenames = []
dst_dir.mkdir(parents=True, exist_ok=True)
for i in range(count):
filename = dst_dir / f"img_{i}.jpg"
filename.write_bytes(generate_image_file(filename.name).getvalue())
filenames.append(filename)
return filenames
class TestCliBase:
@pytest.fixture(autouse=True)
def setup(
self,
restore_db_per_function, # force fixture call order to allow DB setup
restore_redis_inmem_per_function,
restore_redis_ondisk_per_function,
fxt_stdout: io.StringIO,
tmp_path: Path,
admin_user: str,
):
self.tmp_path = tmp_path
self.stdout = fxt_stdout
self.host, self.port = BASE_URL.rsplit(":", maxsplit=1)
self.user = admin_user
self.password = USER_PASS
self.client = make_client(
host=self.host, port=self.port, credentials=(self.user, self.password)
)
self.client.config.status_check_period = 0.01
yield
def run_cli(
self,
cmd: str,
*args: str,
expected_code: int = 0,
organization: str | None = None,
authenticate: bool = True,
) -> str:
common_args = [
f"--server-host={self.host}",
f"--server-port={self.port}",
]
if authenticate:
common_args += [
f"--auth={self.user}:{self.password}",
]
if organization is not None:
common_args.append(f"--organization={organization}")
run_cli(
self,
*common_args,
cmd,
*args,
expected_code=expected_code,
)
return self.stdout.getvalue()
+9
View File
@@ -0,0 +1,9 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
# Force execution of fixture definitions
from shared.fixtures.data import * # pylint: disable=wildcard-import
from shared.fixtures.init import * # pylint: disable=wildcard-import
from shared.fixtures.s3 import * # pylint: disable=wildcard-import
from shared.fixtures.util import * # pylint: disable=wildcard-import
+6
View File
@@ -0,0 +1,6 @@
[tool.isort]
profile = "black"
forced_separate = ["tests"]
line_length = 100
skip_gitignore = true # align tool behavior with Black
known_first_party = ["shared", "rest_api", "sdk", "cli"]
+15
View File
@@ -0,0 +1,15 @@
[pytest]
required_plugins = pytest-timeout pytest-cases
addopts = --verbose --capture=tee-sys
# We expect no regular individual test to run too long
# can be overridden for specific tests with a test decorator
timeout = 15
markers =
with_external_services: The test requires services extrernal to the default CVAT deployment, e.g. a Git server etc.
filterwarnings =
ignore::DeprecationWarning:cvat_sdk.core
strict = True
+16
View File
@@ -0,0 +1,16 @@
allure-pytest==2.14.2
av==16.0.1
pytest==9.0.3 # https://github.com/smarie/python-pytest-cases/issues/385
pytest-cases==3.10.1
pytest-timeout==2.4.0
pytest-cov==7.1.0
pytest-xdist==3.8.0
requests==2.33.0
deepdiff==8.6.2
boto3==1.35.95
Pillow==12.2.0
python-dateutil==2.8.2
pyyaml==6.0.3
numpy==2.3.2; python_version >= "3.14"
numpy==2.0.0; python_version < "3.14"
librosa==0.11.0
+3
View File
@@ -0,0 +1,3 @@
# Copyright (C) 2022 Intel Corporation
#
# SPDX-License-Identifier: MIT
+905
View File
@@ -0,0 +1,905 @@
import io
import math
import os
from collections.abc import Mapping, Sequence
from functools import partial
from typing import IO
import numpy as np
import pytest
from attrs.converters import to_bool
from cvat_sdk.api_client import models
from PIL import Image
from pytest_cases import fixture, fixture_ref, parametrize
import shared.utils.s3 as s3
from rest_api.utils import calc_end_frame, create_task, iter_exclude, unique
from shared.fixtures.init import container_exec_cvat
from shared.tasks.enums import SourceDataType
from shared.tasks.interface import ITaskSpec
from shared.tasks.types import ImagesTaskSpec, VideoTaskSpec
from shared.tasks.utils import parse_frame_step
from shared.utils.config import SHARE_DIR, make_api_client
from shared.utils.helpers import generate_image_files, generate_video_file
def read_share_file(path: str) -> io.BytesIO:
data = io.BytesIO((SHARE_DIR / path).read_bytes())
data.name = path
return data
class TestTasksBase:
_USERNAME = "admin1"
def _image_task_fxt_base(
self,
request: pytest.FixtureRequest,
*,
frame_count: int | None = 10,
image_files: Sequence[io.BytesIO] | None = None,
related_files: Mapping[int, Sequence[io.BytesIO]] | None = None,
start_frame: int | None = None,
stop_frame: int | None = None,
step: int | None = None,
segment_size: int | None = None,
server_files: Sequence[str] | None = None,
cloud_storage_id: int | None = None,
job_replication: int | None = None,
**data_kwargs,
) -> tuple[ImagesTaskSpec, int]:
task_params = {
"name": f"{request.node.name}[{request.fixturename}]",
"labels": [{"name": "a"}],
**({"segment_size": segment_size} if segment_size else {}),
**({"consensus_replicas": job_replication} if job_replication else {}),
}
if server_files is not None:
assert (
image_files is not None
), "'server_files' must be used together with 'image_files'"
else:
assert bool(image_files) ^ bool(
frame_count
), "Expected only one of 'image_files' and 'frame_count'"
if not image_files:
image_files = generate_image_files(frame_count)
images_data = [f.getvalue() for f in image_files]
resulting_task_size = len(
range(start_frame or 0, (stop_frame or len(images_data) - 1) + 1, step or 1)
)
data_params = {
"image_quality": 70,
"sorting_method": "natural",
"chunk_size": max(1, (segment_size or resulting_task_size) // 2),
**(
{
"server_files": server_files,
"cloud_storage_id": cloud_storage_id,
}
if server_files
else {"client_files": image_files}
),
}
data_params.update(data_kwargs)
if start_frame is not None:
data_params["start_frame"] = start_frame
if stop_frame is not None:
data_params["stop_frame"] = stop_frame
if step is not None:
data_params["frame_filter"] = f"step={step}"
def get_frame(i: int) -> bytes:
return images_data[i]
if related_files is not None:
def get_related_files(i: int) -> Mapping[str, bytes]:
frame_ri = related_files.get(i)
common_prefix = ""
if frame_ri:
common_prefix = os.path.commonpath(os.path.dirname(f.name) for f in frame_ri)
return {os.path.relpath(f.name, common_prefix): f.getvalue() for f in frame_ri}
task_id, _ = create_task(self._USERNAME, spec=task_params, data=data_params)
return (
ImagesTaskSpec(
models.TaskWriteRequest._from_openapi_data(**task_params),
models.DataRequest._from_openapi_data(**data_params),
get_frame=get_frame,
get_related_files=get_related_files if related_files else None,
size=resulting_task_size,
),
task_id,
)
@pytest.fixture(scope="class")
def fxt_uploaded_images_task(self, request: pytest.FixtureRequest) -> tuple[ITaskSpec, int]:
return self._image_task_fxt_base(request=request)
@pytest.fixture(scope="class")
def fxt_uploaded_images_task_with_segments(
self, request: pytest.FixtureRequest
) -> tuple[ITaskSpec, int]:
return self._image_task_fxt_base(request=request, segment_size=4)
@fixture(scope="class")
@parametrize("step", [2, 5])
@parametrize("stop_frame", [15, 26])
@parametrize("start_frame", [3, 7])
def fxt_uploaded_images_task_with_segments_start_stop_step(
self, request: pytest.FixtureRequest, start_frame: int, stop_frame: int | None, step: int
) -> tuple[ITaskSpec, int]:
return self._image_task_fxt_base(
request=request,
frame_count=30,
segment_size=4,
start_frame=start_frame,
stop_frame=stop_frame,
step=step,
)
@pytest.fixture(scope="class")
def fxt_uploaded_images_task_with_segments_and_consensus(
self, request: pytest.FixtureRequest
) -> tuple[ITaskSpec, int]:
return self._image_task_fxt_base(request=request, segment_size=4, job_replication=2)
def _image_task_with_honeypots_and_segments_base(
self,
request: pytest.FixtureRequest,
*,
start_frame: int | None = None,
step: int | None = None,
random_seed: int = 42,
image_files: Sequence[io.BytesIO] | None = None,
server_files: Sequence[str] | None = None,
cloud_storage_id: int | None = None,
**kwargs,
) -> tuple[ITaskSpec, int]:
validation_params = models.DataRequestValidationParams._from_openapi_data(
mode="gt_pool",
frame_selection_method="random_uniform",
random_seed=random_seed,
frame_count=5,
frames_per_job_count=2,
)
used_frames_count = 15
total_frame_count = (start_frame or 0) + used_frames_count * (step or 1)
base_segment_size = 4
regular_frame_count = used_frames_count - validation_params.frame_count
final_segment_size = base_segment_size + validation_params.frames_per_job_count
final_task_size = (
regular_frame_count
+ validation_params.frames_per_job_count
* math.ceil(regular_frame_count / base_segment_size)
+ validation_params.frame_count
)
if image_files:
if len(image_files) != total_frame_count:
raise ValueError(
f"If provided, image_files must contain {total_frame_count} images"
)
else:
image_files = generate_image_files(total_frame_count)
task_spec, task_id = self._image_task_fxt_base(
request=request,
frame_count=None,
image_files=image_files,
segment_size=base_segment_size,
sorting_method="random",
start_frame=start_frame,
step=step,
validation_params=validation_params,
server_files=server_files,
cloud_storage_id=cloud_storage_id,
**kwargs,
)
# Get the actual frame order after the task is created
with make_api_client(self._USERNAME) as api_client:
task_meta, _ = api_client.tasks_api.retrieve_data_meta(task_id)
frame_map = [
next(i for i, f in enumerate(image_files) if f.name == frame_info.name)
for frame_info in task_meta.frames
]
_get_frame = task_spec._get_frame
task_spec._get_frame = lambda i: _get_frame(frame_map[i])
task_spec.size = final_task_size
task_spec._params.segment_size = final_segment_size
# These parameters are not applicable to the resulting task,
# they are only effective during task creation
if start_frame or step:
task_spec._data_params.start_frame = 0
task_spec._data_params.stop_frame = task_spec.size
task_spec._data_params.frame_filter = ""
return task_spec, task_id
@fixture(scope="class")
def fxt_uploaded_images_task_with_honeypots_and_segments(
self, request: pytest.FixtureRequest
) -> tuple[ITaskSpec, int]:
return self._image_task_with_honeypots_and_segments_base(request)
@fixture(scope="class")
@parametrize("start_frame, step", [(2, 3)])
def fxt_uploaded_images_task_with_honeypots_and_segments_start_step(
self, request: pytest.FixtureRequest, start_frame: int | None, step: int | None
) -> tuple[ITaskSpec, int]:
return self._image_task_with_honeypots_and_segments_base(
request, start_frame=start_frame, step=step
)
def _images_task_with_honeypots_and_changed_real_frames_base(
self, request: pytest.FixtureRequest, **kwargs
):
task_spec, task_id = self._image_task_with_honeypots_and_segments_base(
request, start_frame=2, step=3, **kwargs
)
with make_api_client(self._USERNAME) as api_client:
validation_layout, _ = api_client.tasks_api.retrieve_validation_layout(task_id)
validation_frames = validation_layout.validation_frames
new_honeypot_real_frames = [
validation_frames[(validation_frames.index(f) + 1) % len(validation_frames)]
for f in validation_layout.honeypot_real_frames
]
api_client.tasks_api.partial_update_validation_layout(
task_id,
patched_task_validation_layout_write_request=(
models.PatchedTaskValidationLayoutWriteRequest(
frame_selection_method="manual",
honeypot_real_frames=new_honeypot_real_frames,
)
),
)
# Get the new frame order
frame_map = dict(zip(validation_layout.honeypot_frames, new_honeypot_real_frames))
_get_frame = task_spec._get_frame
task_spec._get_frame = lambda i: _get_frame(frame_map.get(i, i))
return task_spec, task_id
@fixture(scope="class")
@parametrize("random_seed", [1, 2, 5])
def fxt_uploaded_images_task_with_honeypots_and_changed_real_frames(
self, request: pytest.FixtureRequest, random_seed: int
) -> tuple[ITaskSpec, int]:
return self._images_task_with_honeypots_and_changed_real_frames_base(
request, random_seed=random_seed
)
@fixture(scope="class")
@parametrize(
"cloud_storage_id",
[pytest.param(2, marks=[pytest.mark.with_external_services, pytest.mark.timeout(60)])],
)
def fxt_cloud_images_task_with_honeypots_and_changed_real_frames(
self, request: pytest.FixtureRequest, cloud_storages, cloud_storage_id: int
) -> tuple[ITaskSpec, int]:
cloud_storage = cloud_storages[cloud_storage_id]
s3_client = s3.make_client(bucket=cloud_storage["resource"])
image_files = generate_image_files(47)
for image in image_files:
image.name = f"test/{image.name}"
image.seek(0)
s3_client.create_file(data=image, filename=image.name)
request.addfinalizer(partial(s3_client.remove_file, filename=image.name))
server_files = [f.name for f in image_files]
for image in image_files:
image.seek(0)
return self._images_task_with_honeypots_and_changed_real_frames_base(
request,
image_files=image_files,
server_files=server_files,
cloud_storage_id=cloud_storage_id,
# FIXME: random sorting with frame filter and cloud images (and, optionally, honeypots)
# doesn't work with static cache
# https://github.com/cvat-ai/cvat/issues/9021
use_cache=True,
)
def _uploaded_images_task_with_gt_and_segments_base(
self,
request: pytest.FixtureRequest,
*,
start_frame: int | None = None,
step: int | None = None,
frame_selection_method: str = "random_uniform",
job_replication: int | None = None,
) -> tuple[ITaskSpec, int]:
used_frames_count = 16
total_frame_count = (start_frame or 0) + used_frames_count * (step or 1)
segment_size = 5
image_files = generate_image_files(total_frame_count)
validation_params_kwargs = {"frame_selection_method": frame_selection_method}
if "random" in frame_selection_method:
validation_params_kwargs["random_seed"] = 42
if frame_selection_method == "random_uniform":
validation_frames_count = 10
validation_params_kwargs["frame_count"] = validation_frames_count
elif frame_selection_method == "random_per_job":
frames_per_job_count = 3
validation_params_kwargs["frames_per_job_count"] = frames_per_job_count
validation_frames_count = used_frames_count // segment_size + min(
used_frames_count % segment_size, frames_per_job_count
)
elif frame_selection_method == "manual":
validation_frames_count = 10
valid_frame_ids = range(
(start_frame or 0), (start_frame or 0) + used_frames_count * (step or 1), step or 1
)
rng = np.random.Generator(np.random.MT19937(seed=42))
validation_params_kwargs["frames"] = rng.choice(
[f.name for i, f in enumerate(image_files) if i in valid_frame_ids],
validation_frames_count,
replace=False,
).tolist()
else:
raise NotImplementedError
validation_params = models.DataRequestValidationParams._from_openapi_data(
mode="gt",
**validation_params_kwargs,
)
return self._image_task_fxt_base(
request=request,
frame_count=None,
image_files=image_files,
segment_size=segment_size,
sorting_method="natural",
start_frame=start_frame,
step=step,
validation_params=validation_params,
job_replication=job_replication,
)
@pytest.fixture(scope="class")
def fxt_uploaded_images_task_with_gt_and_segments_and_consensus(
self, request: pytest.FixtureRequest
) -> tuple[ITaskSpec, int]:
return self._uploaded_images_task_with_gt_and_segments_base(
request=request, job_replication=2
)
@fixture(scope="class")
@parametrize(
"cloud_storage_id",
[
pytest.param(
1,
marks=[
pytest.mark.with_external_services,
pytest.mark.timeout(60),
pytest.mark.xfail(
to_bool(os.getenv("CVAT_ALLOW_STATIC_CACHE", False)),
reason="Creating a task from a cloud .bin file with static cache doesn't work at the moment",
),
],
)
],
)
def fxt_cloud_bin_pointcloud_task(
self, request: pytest.FixtureRequest, cloud_storages, cloud_storage_id: int
) -> tuple[ITaskSpec, int]:
cloud_storage = cloud_storages[cloud_storage_id]
s3_client = s3.make_client(bucket=cloud_storage["resource"])
server_files = ["bin_pointcloud/000002.bin"]
bin_files = []
for filename in server_files:
bin_file = io.BytesIO(s3_client.download_fileobj(filename))
bin_file.name = filename
bin_files.append(bin_file)
return self._image_task_fxt_base(
request,
image_files=bin_files,
server_files=server_files,
cloud_storage_id=cloud_storage_id,
)
@fixture(scope="class")
@parametrize(
"cloud_storage_id",
[pytest.param(2, marks=[pytest.mark.with_external_services, pytest.mark.timeout(60)])],
)
def fxt_cloud_images_task_with_related_images(
self, request: pytest.FixtureRequest, cloud_storages, cloud_storage_id: int
) -> tuple[ITaskSpec, int]:
cloud_storage = cloud_storages[cloud_storage_id]
s3_client = s3.make_client(bucket=cloud_storage["resource"])
image_files = generate_image_files(5)
def _upload_file(file: io.RawIOBase):
s3_client.create_file(data=file, filename=file.name)
request.addfinalizer(partial(s3_client.remove_file, filename=file.name))
related_files = []
for i, image in enumerate(image_files):
image.name = f"test/{image.name}"
image.seek(0)
_upload_file(image)
image_related_files = generate_image_files(i)
related_files.append(image_related_files)
for related_file in image_related_files:
assert related_file.name.endswith(".jpeg")
related_file.name = "{}/related_images/{}/{}".format(
os.path.dirname(image.name),
os.path.basename(image.name).replace(".", "_"),
related_file.name,
)
related_file.seek(0)
_upload_file(related_file)
server_files = [f.name for f in image_files] + [
f.name for rfs in related_files for f in rfs
]
for image in image_files:
image.seek(0)
return self._image_task_fxt_base(
request,
image_files=image_files,
related_files=dict(enumerate(related_files)),
server_files=server_files,
cloud_storage_id=cloud_storage_id,
)
@fixture(scope="class")
@parametrize(
"cloud_storage_id",
[pytest.param(1, marks=[pytest.mark.with_external_services, pytest.mark.timeout(60)])],
)
def fxt_cloud_pcd_task_with_related_images(
self, request: pytest.FixtureRequest, cloud_storages, cloud_storage_id: int
) -> tuple[ITaskSpec, int]:
cloud_storage = cloud_storages[cloud_storage_id]
s3_client = s3.make_client(bucket=cloud_storage["resource"])
server_files = [
"pcd_with_related/pointcloud/000001.pcd",
"pcd_with_related/pointcloud/000002.pcd",
"pcd_with_related/pointcloud/000003.pcd",
"pcd_with_related/related_images/000001_pcd/000001.png",
"pcd_with_related/related_images/000002_pcd/000002.png",
"pcd_with_related/related_images/000003_pcd/000003.png",
]
pcd_files = []
for filename in [
"pcd_with_related/pointcloud/000001.pcd",
"pcd_with_related/pointcloud/000002.pcd",
"pcd_with_related/pointcloud/000003.pcd",
]:
pcd_file = io.BytesIO(s3_client.download_fileobj(filename))
pcd_file.name = filename
pcd_files.append(pcd_file)
related_files = []
for filename in [
"pcd_with_related/related_images/000001_pcd/000001.png",
"pcd_with_related/related_images/000002_pcd/000002.png",
"pcd_with_related/related_images/000003_pcd/000003.png",
]:
ri_file = io.BytesIO(s3_client.download_fileobj(filename))
ri_file.name = os.path.basename(filename)
related_files.append([ri_file])
return self._image_task_fxt_base(
request,
image_files=pcd_files,
related_files=dict(enumerate(related_files)),
server_files=server_files,
cloud_storage_id=cloud_storage_id,
)
def _share_images_task_with_related_images(
self,
request: pytest.FixtureRequest,
**data_kwargs,
) -> tuple[ITaskSpec, int]:
image_files = [
read_share_file(fn)
for fn in [
"images/with_related/image_0.png",
"images/with_related/image_1.png",
"images/with_related/image_2.png",
"images/with_related/image_3.png",
]
]
related_files = {
0: [
"images/with_related/related_images/image_0_png/30.png",
"images/with_related/related_images/image_0_png/31.png",
"images/with_related/related_images/image_0_png/33.png",
],
1: [
"images/with_related/related_images/image_1_png/30.png",
],
2: [
"images/with_related/related_images/image_2_png/32.png",
"images/with_related/related_images/image_2_png/33.png",
"images/with_related/related_images/image_2_png/34.png",
],
3: [],
}
for k, fs in related_files.items():
related_files[k] = [read_share_file(fn) for fn in fs]
server_files = [f.name for f in image_files] + [
f.name for fs in related_files.values() for f in fs
]
return self._image_task_fxt_base(
request,
image_files=image_files,
related_files=related_files,
server_files=server_files,
**data_kwargs,
)
@fixture(scope="class")
def fxt_share_images_task_with_related_images(
self, request: pytest.FixtureRequest
) -> tuple[ITaskSpec, int]:
return self._share_images_task_with_related_images(request)
@fixture(scope="class")
@parametrize(
"cloud_storage_id",
[pytest.param(5, marks=[pytest.mark.with_external_services])],
)
def fxt_backing_cs_images_task_with_related_images(
self, request: pytest.FixtureRequest, cloud_storage_id: int
) -> tuple[ITaskSpec, int]:
# The simplest way to get a task with local storage and subdirectories is to use the share
# with copy_data.
task_spec, task_id = self._share_images_task_with_related_images(
request, copy_data=True, use_cache=True
)
container_exec_cvat(
request, ["./manage.py", "movetasktobackingcs", str(task_id), str(cloud_storage_id)]
)
return task_spec, task_id
@fixture(scope="class")
def fxt_share_pcd_task_with_related_images(
self,
request: pytest.FixtureRequest,
) -> tuple[ITaskSpec, int]:
pcd_files = [
read_share_file(fn)
for fn in [
"pcd_with_related/pointcloud/000001.pcd",
"pcd_with_related/pointcloud/000002.pcd",
"pcd_with_related/pointcloud/000003.pcd",
]
]
related_files = {
0: [
"pcd_with_related/related_images/000001_pcd/000001.png",
],
1: [
"pcd_with_related/related_images/000002_pcd/000002.png",
],
2: [
"pcd_with_related/related_images/000003_pcd/000003.png",
],
3: [],
}
for k, fs in related_files.items():
related_files[k] = [read_share_file(fn) for fn in fs]
server_files = [f.name for f in pcd_files] + [
f.name for fs in related_files.values() for f in fs
]
return self._image_task_fxt_base(
request,
image_files=pcd_files,
related_files=related_files,
server_files=server_files,
)
@fixture(scope="class")
@parametrize("start_frame, step", [(2, 3)])
@parametrize("frame_selection_method", ["random_uniform", "random_per_job", "manual"])
def fxt_uploaded_images_task_with_gt_and_segments_start_step(
self,
request: pytest.FixtureRequest,
start_frame: int | None,
step: int | None,
frame_selection_method: str,
) -> tuple[ITaskSpec, int]:
return self._uploaded_images_task_with_gt_and_segments_base(
request,
start_frame=start_frame,
step=step,
frame_selection_method=frame_selection_method,
)
def _uploaded_video_task_fxt_base(
self,
request: pytest.FixtureRequest,
*,
frame_count: int = 10,
segment_size: int | None = None,
start_frame: int | None = None,
stop_frame: int | None = None,
step: int | None = None,
video_file: IO[bytes] | None = None,
chapters: Sequence[dict] | None = None,
) -> tuple[VideoTaskSpec, int]:
task_params = {
"name": f"{request.node.name}[{request.fixturename}]",
"labels": [{"name": "a"}],
}
if segment_size:
task_params["segment_size"] = segment_size
resulting_task_size = len(
range(start_frame or 0, (stop_frame or frame_count - 1) + 1, step or 1)
)
assert (not video_file) ^ (
chapters is not None
), "Chapters must be specified with a custom video file"
if not video_file:
video_file = generate_video_file(frame_count)
video_data = video_file.getvalue()
chapters = [
{
"start": 0,
"stop": 5,
"metadata": {"title": "Intro"},
}
]
else:
video_data = video_file.read()
video_file.seek(0)
data_params = {
"image_quality": 70,
"client_files": [video_file],
"chunk_size": max(1, (segment_size or resulting_task_size) // 2),
}
if start_frame is not None:
data_params["start_frame"] = start_frame
if stop_frame is not None:
data_params["stop_frame"] = stop_frame
if step is not None:
data_params["frame_filter"] = f"step={step}"
def get_video_file() -> io.BytesIO:
return io.BytesIO(video_data)
task_id, _ = create_task(self._USERNAME, spec=task_params, data=data_params)
return (
VideoTaskSpec(
models.TaskWriteRequest._from_openapi_data(**task_params),
models.DataRequest._from_openapi_data(**data_params),
get_video_file=get_video_file,
size=resulting_task_size,
chapters=chapters,
),
task_id,
)
@pytest.fixture(scope="class")
def fxt_uploaded_video_task(
self,
request: pytest.FixtureRequest,
) -> tuple[ITaskSpec, int]:
return self._uploaded_video_task_fxt_base(request=request)
@pytest.fixture(scope="class")
def fxt_uploaded_video_task_without_manifest(
self,
request: pytest.FixtureRequest,
) -> tuple[ITaskSpec, int]:
video_file = generate_video_file(num_frames=10, invalid_keyframes=True)
return self._uploaded_video_task_fxt_base(
request=request, video_file=video_file, chapters=[]
)
@pytest.fixture(scope="class")
def fxt_uploaded_video_task_with_segments(
self, request: pytest.FixtureRequest
) -> tuple[ITaskSpec, int]:
return self._uploaded_video_task_fxt_base(request=request, segment_size=4)
@fixture(scope="class")
@parametrize("step", [2, 5])
@parametrize("stop_frame", [15, 26])
@parametrize("start_frame", [3, 7])
def fxt_uploaded_video_task_with_segments_start_stop_step(
self, request: pytest.FixtureRequest, start_frame: int, stop_frame: int | None, step: int
) -> tuple[ITaskSpec, int]:
return self._uploaded_video_task_fxt_base(
request=request,
frame_count=30,
segment_size=4,
start_frame=start_frame,
stop_frame=stop_frame,
step=step,
)
def _compute_annotation_segment_params(self, task_spec: ITaskSpec) -> list[tuple[int, int]]:
segment_params = []
frame_step = task_spec.frame_step
segment_size = getattr(task_spec, "segment_size", 0) or task_spec.size * frame_step
start_frame = getattr(task_spec, "start_frame", 0)
stop_frame = getattr(task_spec, "stop_frame", None) or (
start_frame + (task_spec.size - 1) * frame_step
)
end_frame = calc_end_frame(start_frame, stop_frame, frame_step)
validation_params = getattr(task_spec, "validation_params", None)
if validation_params and validation_params.mode.value == "gt_pool":
end_frame = min(
end_frame, (task_spec.size - validation_params.frame_count) * frame_step
)
segment_size = min(segment_size, end_frame - 1)
overlap = min(
(
getattr(task_spec, "overlap", None) or 0
if task_spec.source_data_type == SourceDataType.images
else 5
),
segment_size // 2,
)
segment_start = start_frame
while segment_start < end_frame:
if start_frame < segment_start:
segment_start -= overlap * frame_step
segment_end = segment_start + frame_step * segment_size
segment_params.append((segment_start, min(segment_end, end_frame) - frame_step))
segment_start = segment_end
return segment_params
@staticmethod
def _compare_images(
expected: Image.Image, actual: Image.Image, *, must_be_identical: bool = True
):
expected_pixels = np.array(expected)
actual_pixels = np.array(actual)
assert expected_pixels.shape == actual_pixels.shape
if not must_be_identical:
# video chunks can have slightly changed colors, due to codec specifics
# compressed images can also be distorted
assert np.allclose(actual_pixels, expected_pixels, atol=3)
else:
assert np.array_equal(actual_pixels, expected_pixels)
def _get_job_abs_frame_set(self, job_meta: models.DataMetaRead) -> Sequence[int]:
if job_meta.included_frames:
return job_meta.included_frames
else:
return range(
job_meta.start_frame,
job_meta.stop_frame + 1,
parse_frame_step(job_meta.frame_filter),
)
_tasks_with_honeypots_cases = [
fixture_ref("fxt_uploaded_images_task_with_honeypots_and_segments"),
fixture_ref("fxt_uploaded_images_task_with_honeypots_and_segments_start_step"),
fixture_ref("fxt_uploaded_images_task_with_honeypots_and_changed_real_frames"),
fixture_ref("fxt_cloud_images_task_with_honeypots_and_changed_real_frames"),
]
_tasks_with_simple_gt_job_cases = [
fixture_ref("fxt_uploaded_images_task_with_gt_and_segments_start_step"),
fixture_ref("fxt_uploaded_images_task_with_gt_and_segments_and_consensus"),
]
_tasks_with_consensus_cases = [
fixture_ref("fxt_uploaded_images_task_with_segments_and_consensus"),
fixture_ref("fxt_uploaded_images_task_with_gt_and_segments_and_consensus"),
]
_tests_with_cloud_storage_cases = [
fixture_ref("fxt_cloud_bin_pointcloud_task"),
fixture_ref("fxt_cloud_images_task_with_honeypots_and_changed_real_frames"),
fixture_ref("fxt_cloud_images_task_with_related_images"),
]
_tests_with_share_cases = [
fixture_ref("fxt_share_images_task_with_related_images"),
fixture_ref("fxt_share_pcd_task_with_related_images"),
]
_tests_with_related_files_cases = [
fixture_ref("fxt_cloud_images_task_with_related_images"),
fixture_ref("fxt_cloud_pcd_task_with_related_images"),
fixture_ref("fxt_share_images_task_with_related_images"),
fixture_ref("fxt_share_pcd_task_with_related_images"),
fixture_ref("fxt_backing_cs_images_task_with_related_images"),
]
_3d_task_cases = [
fixture_ref("fxt_cloud_bin_pointcloud_task"),
fixture_ref("fxt_cloud_pcd_task_with_related_images"),
fixture_ref("fxt_share_pcd_task_with_related_images"),
]
# Keep in mind that these fixtures are generated eagerly
# (before each depending test or group of tests),
# e.g. a failing task creation in one the fixtures will fail all the depending tests cases.
_all_task_cases = unique(
[
fixture_ref("fxt_uploaded_images_task"),
fixture_ref("fxt_uploaded_images_task_with_segments"),
fixture_ref("fxt_uploaded_images_task_with_segments_start_stop_step"),
fixture_ref("fxt_uploaded_video_task"),
fixture_ref("fxt_uploaded_video_task_without_manifest"),
fixture_ref("fxt_uploaded_video_task_with_segments"),
fixture_ref("fxt_uploaded_video_task_with_segments_start_stop_step"),
]
+ _tasks_with_honeypots_cases
+ _tasks_with_simple_gt_job_cases
+ _tasks_with_consensus_cases
+ _tests_with_cloud_storage_cases
+ _tests_with_related_files_cases
+ _tests_with_share_cases,
key=lambda fxt_ref: fxt_ref.fixture,
)
_2d_task_cases = list(
iter_exclude(
_all_task_cases,
excludes=set(v.fixture for v in _3d_task_cases),
key=lambda v: v.fixture,
)
)
+480
View File
@@ -0,0 +1,480 @@
import csv
import json
from contextlib import ExitStack
from datetime import datetime, timedelta, timezone
from http import HTTPStatus
from io import StringIO
from time import sleep
import pytest
from cvat_sdk.api_client import exceptions, models
from cvat_sdk.api_client.api_client import ApiClient, Endpoint
from deepdiff import DeepDiff
from pytest_cases import parametrize
from shared.utils.config import make_api_client
from .utils import CollectionSimpleFilterTestBase, export_backup, export_dataset, export_events
@pytest.mark.usefixtures("restore_db_per_function")
class TestPostAccessToken:
@pytest.mark.parametrize("user_group", ["admin", "user", "worker"])
def test_can_create_token(self, users, user_group):
user = next(u for u in users if user_group in u["groups"])
with make_api_client(user["username"]) as api_client:
token, _ = api_client.auth_api.create_access_tokens(
access_token_write_request=models.AccessTokenWriteRequest(name="test token")
)
assert token.value
with make_api_client(access_token=token.value) as api_client:
user, _ = api_client.users_api.retrieve_self()
assert user.username == user["username"]
def test_can_create_token_with_expiration(self, admin_user):
expiration_date = datetime.now(tz=timezone.utc) - timedelta(seconds=1)
with make_api_client(admin_user) as api_client:
token, _ = api_client.auth_api.create_access_tokens(
access_token_write_request=models.AccessTokenWriteRequest(
name="test token", expiry_date=expiration_date
)
)
assert token.expiry_date == expiration_date
with (
make_api_client(access_token=token.value) as api_client,
pytest.raises(exceptions.UnauthorizedException, match="Invalid token"),
):
api_client.users_api.retrieve_self()
@parametrize("is_readonly", [True, False])
def test_can_create_readonly_token(self, admin_user, is_readonly, tasks):
with make_api_client(admin_user) as api_client:
token, _ = api_client.auth_api.create_access_tokens(
access_token_write_request=models.AccessTokenWriteRequest(
name="test token", read_only=is_readonly
)
)
assert token.read_only == is_readonly
with make_api_client(access_token=token.value) as api_client:
user, _ = api_client.users_api.retrieve_self()
assert user.username == admin_user
with ExitStack() as es:
if is_readonly:
es.enter_context(pytest.raises(exceptions.ForbiddenException))
api_client.tasks_api.partial_update(
next(iter(tasks))["id"],
patched_task_write_request=models.PatchedTaskWriteRequest(name="new name"),
)
class TestGetAccessToken:
def test_can_get_access_token(self, admin_user, access_tokens):
expected = next(iter(access_tokens))
with make_api_client(admin_user) as api_client:
_, response = api_client.auth_api.retrieve_access_tokens(expected["id"])
actual = json.loads(response.data)
assert DeepDiff(expected, actual, exclude_paths=["private_key"]) == {}
@parametrize("is_admin", [True, False])
def test_cannot_see_foreign_tokens(self, users, access_tokens_by_username, is_admin):
token_owner, token_owner_tokens = next(iter(access_tokens_by_username.items()))
token = token_owner_tokens[0]
other_user = next(
u
for u in users
if u["username"] != token_owner
if u["is_superuser"] == is_admin
if not access_tokens_by_username.get(u["username"])
)
with make_api_client(other_user["username"]) as api_client:
_, response = api_client.auth_api.retrieve_access_tokens(
token["id"], _check_status=False
)
if is_admin:
assert response.status == HTTPStatus.OK
else:
assert response.status == HTTPStatus.FORBIDDEN
assert api_client.auth_api.list_access_tokens()[0].count == 0
def test_can_get_self(self, access_tokens_by_username):
_, user_tokens = next(iter(access_tokens_by_username.items()))
token = user_tokens[0]
with make_api_client(access_token=token["private_key"]) as api_client:
received_token = json.loads(api_client.auth_api.retrieve_access_tokens_self()[1].data)
assert (
DeepDiff(
token,
received_token,
exclude_paths=["updated_date", "last_used_date", "private_key"],
)
== {}
)
@pytest.mark.usefixtures("restore_db_per_function")
@pytest.mark.parametrize("token_eol_reason", ["expired", "stale", "revoked"])
def test_can_only_see_alive_tokens(self, token_eol_reason: str, admin_user):
with make_api_client(admin_user) as api_client:
if token_eol_reason == "expired":
token_id = api_client.auth_api.create_access_tokens(
access_token_write_request=models.AccessTokenWriteRequest(
name="test token",
expiry_date=datetime.now(timezone.utc) - timedelta(seconds=1),
)
)[0].id
elif token_eol_reason == "revoked":
token_id = api_client.auth_api.create_access_tokens(
access_token_write_request=models.AccessTokenWriteRequest(name="test token")
)[0].id
api_client.auth_api.destroy_access_tokens(token_id)
elif token_eol_reason == "stale":
token_id = 6
else:
assert False, f"Unexpected token eol reason '{token_eol_reason}'"
with pytest.raises(
exceptions.NotFoundException, match="No AccessToken matches the given query"
):
api_client.auth_api.retrieve_access_tokens(token_id)
assert (
api_client.auth_api.list_access_tokens(
filter=json.dumps({"==": [{"var": "id"}, token_id]})
)[0].count
== 0
)
class TestAccessTokenListFilters(CollectionSimpleFilterTestBase):
@pytest.fixture(autouse=True)
def setup(self, restore_db_per_class, users_by_name, raw_access_tokens_by_username):
# Only own keys are visible to each user
self.user, self.samples = next(
(username, user_tokens)
for username, user_tokens in raw_access_tokens_by_username.items()
if users_by_name[username]["is_superuser"] and user_tokens
)
def _get_endpoint(self, api_client: ApiClient) -> Endpoint:
return api_client.auth_api.list_access_tokens_endpoint
@pytest.mark.parametrize("field", ("name", "read_only"))
def test_can_use_simple_filter_for_object_list(self, field):
return super()._test_can_use_simple_filter_for_object_list(field)
@pytest.mark.usefixtures("restore_db_per_function")
class TestPatchAccessToken:
def test_can_modify_token(self, access_tokens_by_username):
user, user_tokens = next(iter(access_tokens_by_username.items()))
token = user_tokens[0]
updated_values = {
"name": "new name",
"expiry_date": datetime.now(timezone.utc) + timedelta(days=1),
"read_only": not token["read_only"],
}
updated_token = dict(token)
updated_token.update(updated_values)
with make_api_client(user) as api_client:
_, response = api_client.auth_api.partial_update_access_tokens(
token["id"],
patched_access_token_write_request=models.PatchedAccessTokenWriteRequest(
**updated_values
),
)
received_token = json.loads(response.data)
updated_token["expiry_date"] = (
updated_token["expiry_date"].isoformat().replace("+00:00", "Z")
)
assert (
DeepDiff(updated_token, received_token, exclude_paths=["updated_date", "private_key"])
== {}
)
def test_cannot_modify_foreign_token(self, users, access_tokens_by_username):
token_owner, token_owner_tokens = next(iter(access_tokens_by_username.items()))
token = token_owner_tokens[0]
other_user = next(
u["username"] for u in users if u["username"] != token_owner and not u["is_superuser"]
)
with (
make_api_client(other_user) as api_client,
pytest.raises(exceptions.ForbiddenException),
):
api_client.auth_api.partial_update_access_tokens(
token["id"],
patched_access_token_write_request=models.PatchedAccessTokenWriteRequest(
name="new name"
),
)
def test_cannot_modify_expired_token(self, admin_user):
with make_api_client(admin_user) as api_client:
token_id = api_client.auth_api.create_access_tokens(
access_token_write_request=models.AccessTokenWriteRequest(
name="test token",
expiry_date=datetime.now(timezone.utc) - timedelta(seconds=1),
)
)[0].id
with pytest.raises(
exceptions.NotFoundException, match="No AccessToken matches the given query"
):
api_client.auth_api.partial_update_access_tokens(
token_id,
patched_access_token_write_request=models.PatchedAccessTokenWriteRequest(
expiry_date=datetime.now(timezone.utc) + timedelta(days=1)
),
)
@pytest.mark.usefixtures("restore_db_per_function")
class TestDeleteAccessToken:
def test_can_revoke_own_token(self, access_tokens_by_username):
user, user_tokens = next(iter(access_tokens_by_username.items()))
token = user_tokens[0]
with make_api_client(user) as api_client:
api_client.auth_api.destroy_access_tokens(token["id"])
def test_cannot_revoke_foreign_token(self, users, access_tokens_by_username):
token_owner, token_owner_tokens = next(iter(access_tokens_by_username.items()))
token = token_owner_tokens[0]
other_user = next(
u["username"] for u in users if u["username"] != token_owner and not u["is_superuser"]
)
with (
make_api_client(other_user) as api_client,
pytest.raises(exceptions.ForbiddenException),
):
api_client.auth_api.destroy_access_tokens(token["id"])
class TestTokenAuthPermissions:
# Some operations are not allowed with token auth for security reasons, even if not read only
def test_cannot_change_password_when_using_token_auth(
self, admin_user, access_tokens_by_username
):
token = next(t for t in access_tokens_by_username[admin_user] if not t["read_only"])
with (
make_api_client(access_token=token["private_key"]) as api_client,
pytest.raises(exceptions.UnauthorizedException),
):
api_client.auth_api.create_password_change(
password_change_request=models.PasswordChangeRequest(
old_password="any", new_password1="anypass1", new_password2="anypass1"
)
)
def test_cannot_create_token_when_using_token_auth(self, admin_user, access_tokens_by_username):
token = next(t for t in access_tokens_by_username[admin_user] if not t["read_only"])
with (
make_api_client(access_token=token["private_key"]) as api_client,
pytest.raises(exceptions.ForbiddenException),
):
api_client.auth_api.create_access_tokens(
access_token_write_request=models.AccessTokenWriteRequest(name="test token")
)
def test_cannot_edit_token_when_using_token_auth(self, admin_user, access_tokens_by_username):
token = next(t for t in access_tokens_by_username[admin_user] if not t["read_only"])
with (
make_api_client(access_token=token["private_key"]) as api_client,
pytest.raises(exceptions.ForbiddenException),
):
api_client.auth_api.partial_update_access_tokens(
token["id"],
patched_access_token_write_request=models.PatchedAccessTokenWriteRequest(
name="new name"
),
)
def test_cannot_revoke_token_when_using_token_auth(self, admin_user, access_tokens_by_username):
token = next(t for t in access_tokens_by_username[admin_user] if not t["read_only"])
with (
make_api_client(access_token=token["private_key"]) as api_client,
pytest.raises(exceptions.ForbiddenException),
):
api_client.auth_api.destroy_access_tokens(token["id"])
def test_cannot_edit_user_when_using_token_auth(
self, admin_user, access_tokens_by_username, users_by_name
):
token = next(t for t in access_tokens_by_username[admin_user] if not t["read_only"])
user = users_by_name[admin_user]
with (
make_api_client(access_token=token["private_key"]) as api_client,
pytest.raises(exceptions.ForbiddenException),
):
api_client.users_api.partial_update(
user["id"], patched_user_request=models.PatchedUserRequest(first_name="new name")
)
@pytest.mark.usefixtures("restore_redis_ondisk_per_function")
@parametrize("is_readonly", [True, False])
@parametrize("export_type", ["annotations", "dataset", "backup"])
def test_token_can_export_project(
self, admin_user, access_tokens_by_username, projects, export_type, is_readonly
):
token = next(
t for t in access_tokens_by_username[admin_user] if t["read_only"] == is_readonly
)
project_id = next(p["id"] for p in projects if p["tasks"]["count"] > 0)
with make_api_client(access_token=token["private_key"]) as api_client:
if export_type in ("annotations", "dataset"):
export_dataset(
api_client.projects_api, id=project_id, save_images=(export_type == "dataset")
)
elif export_type == "backup":
export_backup(api_client.projects_api, id=project_id)
@pytest.mark.usefixtures("restore_redis_ondisk_per_function")
@parametrize("is_readonly", [True, False])
@parametrize("export_type", ["annotations", "dataset", "backup"])
def test_token_can_export_task(
self, admin_user, access_tokens_by_username, tasks, export_type, is_readonly
):
token = next(
t for t in access_tokens_by_username[admin_user] if t["read_only"] == is_readonly
)
task_id = next(p["id"] for p in tasks if p["size"] > 0 if p["media_type"] != "audio")
with make_api_client(access_token=token["private_key"]) as api_client:
if export_type in ("annotations", "dataset"):
export_dataset(
api_client.tasks_api, id=task_id, save_images=(export_type == "dataset")
)
elif export_type == "backup":
export_backup(api_client.tasks_api, id=task_id)
@pytest.mark.usefixtures("restore_redis_ondisk_per_function")
@parametrize("is_readonly", [True, False])
@parametrize("export_type", ["annotations", "dataset"])
def test_token_can_export_jobs(
self, admin_user, access_tokens_by_username, jobs, export_type, is_readonly
):
token = next(
t for t in access_tokens_by_username[admin_user] if t["read_only"] == is_readonly
)
job_id = next(p["id"] for p in jobs if p["media_type"] != "audio")
with make_api_client(access_token=token["private_key"]) as api_client:
if export_type in ("annotations", "dataset"):
export_dataset(
api_client.jobs_api, id=job_id, save_images=(export_type == "dataset")
)
elif export_type == "backup":
export_backup(api_client.jobs_api, id=job_id)
@pytest.mark.usefixtures("restore_db_per_function")
class TestTokenTracking:
def test_can_update_last_use(self, access_tokens_by_username):
_, user_tokens = next(iter(access_tokens_by_username.items()))
token = user_tokens[0]
with make_api_client(access_token=token["private_key"]) as api_client:
updated_token, _ = api_client.auth_api.retrieve_access_tokens_self()
old_last_used_date = token["last_used_date"]
if old_last_used_date is not None:
old_last_used_date = datetime.fromisoformat(
token["last_used_date"].rstrip("Z")
).replace(tzinfo=timezone.utc)
assert updated_token.last_used_date != old_last_used_date
@pytest.mark.usefixtures("restore_redis_inmem_per_function")
def test_can_record_token_events_in_audit_logs(self, admin_user, tasks):
test_start_date = datetime.now(tz=timezone.utc)
with make_api_client(admin_user) as api_client:
token = api_client.auth_api.create_access_tokens(
access_token_write_request=models.AccessTokenWriteRequest(
name="test token", read_only=False
)
)[0]
task_id = next(p["id"] for p in tasks if p["size"] > 0)
with make_api_client(access_token=token.value) as api_client:
api_client.tasks_api.partial_update(
task_id, patched_task_write_request=models.PatchedTaskWriteRequest(name="newname")
)
with make_api_client(admin_user) as api_client:
api_client.auth_api.partial_update_access_tokens(
token.id,
patched_access_token_write_request=models.PatchedAccessTokenWriteRequest(
expiry_date=test_start_date + timedelta(days=1)
),
)
api_client.auth_api.destroy_access_tokens(token.id)
# Clickhouse updates are not immediate, vector has buffering on sinks.
# All these checks are in a single test because of this extra waiting.
# Potentially unstable, should probably be improved or maybe moved into server tests.
sleep(5)
events_csv = export_events(api_client, api_version=2, _from=test_start_date)
csv_reader = csv.DictReader(StringIO(events_csv.decode()))
rows = list(csv_reader)
def find_row(scope: str):
return next((i, r) for i, r in enumerate(rows) if r["scope"] == scope)
token_creation_row_index, row = find_row(scope="create:accesstoken")
assert row["obj_id"] == str(token.id)
task_update_row_index, row = find_row(scope="update:task")
assert row["task_id"] == str(task_id)
assert row["access_token_id"] == str(token.id)
assert row["obj_name"] == "name"
assert row["obj_val"] == "newname"
token_update_row_index, row = find_row(scope="update:accesstoken")
# token id is not present in the "update:*" event fields, can't check it
assert row["obj_name"] == "expiry_date"
token_delete_row_index, row = find_row(scope="delete:accesstoken")
assert row["obj_id"] == str(token.id)
assert token_creation_row_index < task_update_row_index
assert task_update_row_index < token_update_row_index
assert token_update_row_index < token_delete_row_index
+367
View File
@@ -0,0 +1,367 @@
# Copyright (C) 2022 Intel Corporation
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import csv
import json
import uuid
from collections import Counter
from datetime import datetime, timedelta, timezone
from http import HTTPStatus
from io import StringIO
from time import sleep
import pytest
from dateutil import parser as datetime_parser
import shared.utils.s3 as s3
from shared.utils.config import delete_method, get_method, make_api_client, server_get
from shared.utils.helpers import generate_image_files
from .utils import create_task, export_events
class TestGetAnalytics:
endpoint = "analytics"
def _test_can_see(self, user):
response = server_get(user, self.endpoint)
assert response.status_code == HTTPStatus.OK
def _test_cannot_see(self, user):
response = server_get(user, self.endpoint)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.parametrize(
"conditions, is_allow",
[
(dict(privilege="admin"), True),
(dict(privilege="worker", has_analytics_access=False), False),
(dict(privilege="worker", has_analytics_access=True), True),
(dict(privilege="user", has_analytics_access=False), False),
(dict(privilege="user", has_analytics_access=True), True),
],
)
def test_can_see(self, conditions, is_allow, find_users):
user = find_users(**conditions)[0]["username"]
if is_allow:
self._test_can_see(user)
else:
self._test_cannot_see(user)
@pytest.mark.usefixtures("restore_db_per_class")
class TestGetAuditEvents:
_USERNAME = "admin1"
@staticmethod
def _create_project(user, spec, **kwargs):
with make_api_client(user) as api_client:
project, response = api_client.projects_api.create(spec, **kwargs)
assert response.status == HTTPStatus.CREATED
return project.id, response.headers.get("X-Request-Id")
@pytest.fixture(autouse=True)
def setup(self, restore_clickhouse_db_per_function, restore_redis_inmem_per_function):
project_spec = {
"name": f"Test project created by {self._USERNAME}",
"labels": [
{
"name": "car",
"color": "#ff00ff",
"attributes": [
{
"name": "a",
"mutable": True,
"input_type": "number",
"default_value": "5",
"values": ["4", "5", "6"],
}
],
}
],
}
self.project_id, project_request_id = TestGetAuditEvents._create_project(
self._USERNAME, project_spec
)
task_spec = {
"name": f"test {self._USERNAME} to create a task",
"segment_size": 2,
"project_id": self.project_id,
}
task_ids = [
create_task(
self._USERNAME,
task_spec,
{
"image_quality": 10,
"client_files": generate_image_files(3),
},
),
create_task(
self._USERNAME,
task_spec,
{
"image_quality": 10,
"client_files": generate_image_files(3),
},
),
]
self.task_ids = [t[0] for t in task_ids]
assert project_request_id is not None
assert all(t[1] is not None for t in task_ids)
# Events are pushed to ClickHouse asynchronously,
# so we must wait until the expected number of events for every scope is present.
# Items: (filters, expected_count)
event_filters = [
(
(
(lambda e: json.loads(e["payload"])["request"]["id"], [project_request_id]),
("scope", ["create:project"]),
),
1,
),
]
for task_id, task_request_id in task_ids:
event_filters.extend(
(
(
(
(
lambda e: json.loads(e["payload"])["request"]["id"],
[task_request_id],
),
("scope", ["create:task"]),
),
1,
),
(
(
("task_id", [str(task_id)]),
("scope", ["create:job"]),
),
2,
),
)
)
self._wait_for_request_ids(event_filters)
def _wait_for_request_ids(self, event_filters):
MAX_RETRIES = 5
SLEEP_INTERVAL = 2
while MAX_RETRIES > 0:
data = self._test_get_audit_logs_as_csv()
events = self._csv_to_dict(data)
if all(
len(self._filter_events(events, filters)) >= expected_count
for filters, expected_count in event_filters
):
break
MAX_RETRIES -= 1
sleep(SLEEP_INTERVAL)
else:
assert False, "Could not wait for expected request IDs"
@staticmethod
def _csv_to_dict(csv_data):
res = []
with StringIO(csv_data.decode()) as f:
reader = csv.DictReader(f)
for row in reader:
res.append(row)
return res
@staticmethod
def _filter_events(events, filters):
res = []
get_value = lambda getter, e: getter(e) if callable(getter) else e.get(getter, None)
for e in events:
if all(get_value(getter, e) in expected_values for getter, expected_values in filters):
res.append(e)
return res
def _test_get_audit_logs_as_csv(self, *, api_version: int = 2, **kwargs):
with make_api_client(self._USERNAME) as api_client:
return export_events(api_client, api_version=api_version, **kwargs)
@pytest.mark.parametrize("api_version", [1, 2])
def test_entry_to_time_interval(self, api_version: int):
now = datetime.now(timezone.utc)
to_datetime = now
from_datetime = now - timedelta(minutes=3)
query_params = {
"_from": from_datetime.isoformat(),
"to": to_datetime.isoformat(),
}
data = self._test_get_audit_logs_as_csv(api_version=api_version, **query_params)
events = self._csv_to_dict(data)
assert len(events)
for event in events:
event_timestamp = datetime_parser.isoparse(event["timestamp"])
assert from_datetime <= event_timestamp <= to_datetime
@pytest.mark.parametrize("api_version", [1, 2])
def test_filter_by_project(self, api_version: int):
query_params = {
"project_id": self.project_id,
}
data = self._test_get_audit_logs_as_csv(api_version=api_version, **query_params)
events = self._csv_to_dict(data)
filtered_events = self._filter_events(events, [("project_id", [str(self.project_id)])])
assert len(filtered_events)
assert len(events) == len(filtered_events)
event_count = Counter([e["scope"] for e in filtered_events])
assert event_count["create:project"] == 1
assert event_count["create:task"] == 2
assert event_count["create:job"] == 4
@pytest.mark.parametrize("api_version", [1, 2])
def test_filter_by_task(self, api_version: int):
for task_id in self.task_ids:
query_params = {
"task_id": task_id,
}
data = self._test_get_audit_logs_as_csv(api_version=api_version, **query_params)
events = self._csv_to_dict(data)
filtered_events = self._filter_events(events, [("task_id", [str(task_id)])])
assert len(filtered_events)
assert len(events) == len(filtered_events)
event_count = Counter([e["scope"] for e in filtered_events])
assert event_count["create:task"] == 1
assert event_count["create:job"] == 2
@pytest.mark.parametrize("api_version", [1, 2])
def test_filter_by_non_existent_project(self, api_version: int):
query_params = {
"project_id": self.project_id + 100,
}
data = self._test_get_audit_logs_as_csv(api_version=api_version, **query_params)
events = self._csv_to_dict(data)
assert len(events) == 0
@pytest.mark.parametrize("api_version", [1, 2])
def test_user_and_request_id_not_empty(self, api_version: int):
query_params = {
"project_id": self.project_id,
}
data = self._test_get_audit_logs_as_csv(api_version=api_version, **query_params)
events = self._csv_to_dict(data)
for event in events:
assert event["user_id"]
assert event["user_name"]
assert event["user_email"]
payload = json.loads(event["payload"])
request_id = payload["request"]["id"]
assert request_id
uuid.UUID(request_id)
@pytest.mark.parametrize("api_version", [1, 2])
def test_exported_events_do_not_contain_remote_addr(self, api_version: int):
query_params = {
"project_id": self.project_id,
}
data = self._test_get_audit_logs_as_csv(api_version=api_version, **query_params)
events = self._csv_to_dict(data)
assert len(events)
for event in events:
assert "remote_addr" not in event
@pytest.mark.parametrize("api_version", [1, 2])
def test_delete_project(self, api_version: int):
response = delete_method("admin1", f"projects/{self.project_id}")
assert response.status_code == HTTPStatus.NO_CONTENT
request_id = response.headers.get("X-Request-Id")
# Items: (filters, expected_count). The cascade delete emits one event per
# affected object under the same request id, so wait for all of them.
event_filters = (
(
(
(lambda e: json.loads(e["payload"])["request"]["id"], [request_id]),
("scope", ["delete:project"]),
),
1,
),
(
(
(lambda e: json.loads(e["payload"])["request"]["id"], [request_id]),
("scope", ["delete:task"]),
),
2,
),
(
(
(lambda e: json.loads(e["payload"])["request"]["id"], [request_id]),
("scope", ["delete:job"]),
),
4,
),
)
self._wait_for_request_ids(event_filters)
query_params = {
"project_id": self.project_id,
}
data = self._test_get_audit_logs_as_csv(api_version=api_version, **query_params)
events = self._csv_to_dict(data)
filtered_events = self._filter_events(events, [("project_id", [str(self.project_id)])])
assert len(filtered_events)
assert len(events) == len(filtered_events)
event_count = Counter([e["scope"] for e in filtered_events])
assert event_count["delete:project"] == 1
assert event_count["delete:task"] == 2
assert event_count["delete:job"] == 4
@pytest.mark.with_external_services
@pytest.mark.parametrize("api_version, allowed", [(1, False), (2, True)])
@pytest.mark.parametrize("cloud_storage_id", [3]) # import/export bucket
def test_export_to_cloud(
self, api_version: int, allowed: bool, cloud_storage_id: int, cloud_storages
):
query_params = {
"api_version": api_version,
"location": "cloud_storage",
"cloud_storage_id": cloud_storage_id,
"filename": "test.csv",
"task_id": self.task_ids[0],
}
if allowed:
data = self._test_get_audit_logs_as_csv(**query_params)
assert data is None
s3_client = s3.make_client(bucket=cloud_storages[cloud_storage_id]["resource"])
data = s3_client.download_fileobj(query_params["filename"])
events = self._csv_to_dict(data)
assert len(events)
else:
response = get_method(self._USERNAME, "events", **query_params)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert (
response.json()[0]
== "This endpoint does not support exporting events to cloud storage"
)
+395
View File
@@ -0,0 +1,395 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import io
import math
from collections.abc import Generator
from itertools import product
from pathlib import Path, PurePosixPath
import pytest
from cvat_sdk import exceptions, models
from cvat_sdk.core.exceptions import BackgroundRequestException
from cvat_sdk.core.proxies.tasks import ResourceType, Task
from PIL import Image
from pytest_cases import fixture, fixture_ref, parametrize
import shared.utils.s3 as s3
from shared.utils.config import (
SHARE_DIR,
make_sdk_client,
)
from shared.utils.helpers import read_audio_pcm
from ._test_base import TestTasksBase
@fixture(scope="session")
def fxt_local_audio_with_cover() -> Generator[tuple[Path, Path], None, None]:
# Generate with:
# ffmpeg \
# -i "audio.mp3" \
# -i "cover.png" \
# -map 0:0 \
# -map 1:0 \
# -c copy \
# -id3v2_version 3 \
# "audio_with_cover.mp3"
yield (
SHARE_DIR / "audio" / "sample2_with_cover.mp3",
SHARE_DIR / "audio" / "sample2_cover.png",
)
@pytest.mark.usefixtures("restore_redis_ondisk_after_class")
@pytest.mark.usefixtures("restore_redis_ondisk_per_class")
@pytest.mark.usefixtures("restore_db_per_class")
@pytest.mark.usefixtures("restore_cvat_data_per_class")
class TestAudioTasks:
@pytest.fixture(autouse=True)
def setup(
self,
restore_redis_inmem_per_function,
tmp_path: Path,
admin_user: str,
):
self.tmp_dir = tmp_path
self.user = admin_user
with make_sdk_client(self.user) as client:
self.client = client
yield
@fixture(scope="class")
@parametrize("source_filename", [fixture_ref("fxt_local_audio_file_path")], scope="session")
def fxt_audio_task_from_uploaded_data(
cls, request: pytest.FixtureRequest, admin_user, source_filename: Path
):
with make_sdk_client(admin_user) as client:
yield client.tasks.create_from_data(
spec={
"name": f"{request.node.name}[{request.fixturename}]",
},
resources=[source_filename],
)
@parametrize("task", [fixture_ref(fxt_audio_task_from_uploaded_data)])
def test_can_create_audio_task_from_local_data(self, task: Task):
assert task.media_type == "audio"
assert task.dimension == "1d"
assert task.mode == "interpolation"
assert task.size > 0
@pytest.mark.with_external_services
@parametrize("use_cache", [True, False])
@parametrize(
"cloud_storage_id",
[
1, # public bucket
2, # private bucket
],
)
def test_can_create_audio_task_from_cloud_data(
self,
fxt_test_name: str,
fxt_uploaded_s3_file,
fxt_local_audio_file_path: Path,
use_cache: bool,
cloud_storage_id: int,
cloud_storages,
organizations,
):
cloud_storage = cloud_storages[cloud_storage_id]
org_id = cloud_storages[cloud_storage_id]["organization"]
org_slug = organizations[org_id]["slug"] if org_id else None
storage_media_path = PurePosixPath(fxt_local_audio_file_path.name)
s3_client = s3.make_client(bucket=cloud_storage["resource"])
fxt_uploaded_s3_file(
s3_client, path=storage_media_path, data=fxt_local_audio_file_path.read_bytes()
)
with self.client.organization_context(org_slug):
task = self.client.tasks.create_from_data(
spec={
"name": fxt_test_name,
},
resources=[storage_media_path],
resource_type=ResourceType.SHARE,
data_params={
"cloud_storage_id": cloud_storage_id,
"use_cache": use_cache,
},
)
assert task.media_type == "audio"
assert task.dimension == "1d"
assert task.mode == "interpolation"
assert task.size > 0
assert task.data_cloud_storage_id == cloud_storage_id
@parametrize(
"source_path, cover_image_path",
[
(fixture_ref("fxt_local_audio_file_path"), None),
fixture_ref(fxt_local_audio_with_cover),
],
)
def test_can_use_cover_image_for_preview(self, fxt_test_name, source_path, cover_image_path):
task = self.client.tasks.create_from_data(
spec={"name": fxt_test_name},
resources=[source_path],
)
jobs = task.get_jobs()
for instance in (task, *jobs):
actual = Image.open(instance.get_preview())
assert actual.size > (0, 0)
if cover_image_path is None:
assert actual.format == "PNG" # should return the default image
continue
expected = Image.open(cover_image_path)
expected.thumbnail((256, 256))
TestTasksBase._compare_images(expected, actual, must_be_identical=False)
@parametrize("task", [fixture_ref(fxt_audio_task_from_uploaded_data)])
def test_can_split_into_jobs(self, task: Task):
# Only 1 job is allowed for audio data
assert task.jobs.count == 1
@parametrize("source_filename", [fixture_ref("fxt_local_audio_file_path")])
def test_cant_use_segment_size(self, source_filename: Path, fxt_test_name: str):
with pytest.raises(BackgroundRequestException) as capture:
self.client.tasks.create_from_data(
spec={
"name": fxt_test_name,
"segment_size": 10000,
},
resources=[source_filename],
)
assert "'segment_size' parameter cannot be used in audio tasks" in str(capture.value)
@pytest.mark.timeout(
# This test has to check all the task chunks availability, it can make many requests
timeout=300
)
@parametrize("task", [fixture_ref(fxt_audio_task_from_uploaded_data)])
def test_can_get_task_chunks(self, task: Task):
data_meta = task.get_meta()
assert task.data_chunk_size == task.size
assert task.data_compressed_chunk_type == "audio_mp3"
assert task.data_original_chunk_type == "audio_mp3"
for quality, chunk_id in product(
["original", "compressed"],
range(math.ceil((data_meta.stop_frame - data_meta.start_frame) / data_meta.chunk_size)),
):
response = task.api.retrieve_data(
task.id, type="chunk", quality=quality, number=chunk_id, _parse_response=False
)[1]
chunk_file = io.BytesIO(response.data)
chunk_audio, sampling_rate = read_audio_pcm(chunk_file)
assert chunk_audio.shape[0] / sampling_rate >= data_meta.size / 1000
@pytest.mark.timeout(
# This test has to check all the job chunks availability, it can make many requests
timeout=300
)
@parametrize("task", [fixture_ref(fxt_audio_task_from_uploaded_data)])
@parametrize("indexing", ["absolute", "relative"])
def test_can_get_job_chunks(self, task: Task, indexing: str):
jobs = sorted(task.get_jobs(), key=lambda j: j.start_frame)
assert len(jobs) == 1 # only 1 job is allowed per task so far
for job in jobs:
job_meta = job.get_meta()
assert job.data_chunk_size == job.frame_count
assert job.data_compressed_chunk_type == "audio_mp3"
assert job.data_original_chunk_type == "audio_mp3"
for quality, chunk_id in product(
["original", "compressed"],
range(math.ceil((job_meta.stop_frame - job_meta.start_frame) / job_meta.chunk_size)),
):
response = job.api.retrieve_data(
job.id,
type="chunk",
quality=quality,
**({"number": chunk_id} if indexing == "absolute" else {"index": chunk_id}),
_parse_response=False,
)[1]
chunk_file = io.BytesIO(response.data)
chunk_audio, sampling_rate = read_audio_pcm(chunk_file)
assert chunk_audio.shape[0] / sampling_rate >= job_meta.size / 1000
@parametrize("task", [fixture_ref(fxt_audio_task_from_uploaded_data)])
def test_can_create_gt_job(self, task: Task):
gt_job = self.client.jobs.api.create(
job_write_request=models.JobWriteRequest(type="ground_truth", task_id=task.id)
)[0]
assert gt_job.type == "ground_truth"
assert gt_job.frame_count == task.size
@parametrize("source_filename", [fixture_ref("fxt_local_audio_file_path")])
def test_can_create_task_with_gt_job(self, fxt_test_name: str, source_filename: Path):
task = self.client.tasks.create_from_data(
spec={
"name": fxt_test_name,
},
resources=[source_filename],
data_params={"validation_params": {"mode": "gt"}},
)
gt_job = next(j for j in task.get_jobs() if j.type == "ground_truth")
assert gt_job.type == "ground_truth"
assert gt_job.frame_count == task.size
@parametrize("task", [fixture_ref(fxt_audio_task_from_uploaded_data)])
def test_cant_export_dataset(self, task: Task):
with pytest.raises(BackgroundRequestException) as capture:
task.export_dataset("Generic TSV 1.0", filename=self.tmp_dir, include_images=True)
assert "export as dataset is not supported for audio" in str(capture.value)
@parametrize("task", [fixture_ref(fxt_audio_task_from_uploaded_data)])
def test_cant_import_dataset(self, task: Task, fxt_test_name: str):
project = self.client.projects.create({"name": fxt_test_name})
temp_file = self.tmp_dir / "test.tsv"
temp_file.write_text("test")
with pytest.raises(BackgroundRequestException) as capture:
project.import_dataset("Generic TSV 1.0", filename=temp_file)
assert "import from dataset is not supported for audio" in str(capture.value)
class TestAudioAnnotations:
@pytest.fixture(autouse=True)
def setup(
self,
restore_db_per_function,
admin_user: str,
):
self.user = admin_user
with make_sdk_client(self.user) as client:
self.client = client
yield
@parametrize("instance_type", ["task", "job"])
def test_can_save_intervals(self, tasks, instance_type: str):
task_id = next(t for t in tasks if t["media_type"] == "audio")["id"]
task = self.client.tasks.retrieve(task_id)
task.update({"labels": [{"name": "test", "type": "interval"}]})
label = task.get_labels()[-1]
payload = models.LabeledDataRequest(
intervals=[
models.LabeledIntervalRequest(
label_id=label.id,
start=0,
stop=task.size - 1,
),
]
)
if instance_type == "task":
instance = task
elif instance_type == "job":
instance = task.get_jobs()[0]
else:
assert False, instance_type
instance.set_annotations(payload)
server_annotations = instance.get_annotations()
assert len(server_annotations.intervals) == 1
assert server_annotations.intervals[0].label_id == payload.intervals[0].label_id
assert server_annotations.intervals[0].start == payload.intervals[0].start
assert server_annotations.intervals[0].stop == payload.intervals[0].stop
@parametrize("instance_type", ["task", "job"])
def test_can_save_interval_with_null_stop(self, tasks, instance_type: str):
task_id = next(t for t in tasks if t["media_type"] == "audio")["id"]
task = self.client.tasks.retrieve(task_id)
task.update({"labels": [{"name": "test", "type": "interval"}]})
label = task.get_labels()[-1]
payload = models.LabeledDataRequest(
intervals=[
models.LabeledIntervalRequest(
label_id=label.id,
start=0,
stop=None,
),
]
)
if instance_type == "task":
instance = task
elif instance_type == "job":
instance = task.get_jobs()[0]
else:
assert False, instance_type
instance.set_annotations(payload)
server_annotations = instance.get_annotations()
assert len(server_annotations.intervals) == 1
assert server_annotations.intervals[0].label_id == payload.intervals[0].label_id
assert server_annotations.intervals[0].start == payload.intervals[0].start
assert server_annotations.intervals[0].stop is None
@parametrize("instance_type", ["task", "job"])
def test_cant_save_intervals_outside_range(self, tasks, instance_type: str):
task_id = next(t for t in tasks if t["media_type"] == "audio")["id"]
task = self.client.tasks.retrieve(task_id)
labels = task.get_labels()
payload = models.LabeledDataRequest(
intervals=[
models.LabeledIntervalRequest(
label_id=labels[0].id,
start=0,
stop=task.size,
),
]
)
if instance_type == "task":
instance = task
elif instance_type == "job":
instance = task.get_jobs()[0]
else:
assert False, instance_type
with pytest.raises(exceptions.ApiException) as capture:
instance.set_annotations(payload)
assert "cannot be outside" in str(capture.value)
+327
View File
@@ -0,0 +1,327 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import json
from collections.abc import Generator
from contextlib import contextmanager
from http import HTTPStatus
from unittest import mock
import pytest
from cvat_sdk.api_client import ApiClient, Configuration, models
from shared.utils.config import BASE_URL, USER_PASS, make_api_client
@pytest.mark.usefixtures("restore_db_per_class")
class TestBasicAuth:
def test_can_use_basic_auth(self, admin_user: str):
username = admin_user
config = Configuration(host=BASE_URL, username=username, password=USER_PASS)
with ApiClient(config) as client:
user, response = client.users_api.retrieve_self()
assert response.status == HTTPStatus.OK
assert user.username == username
@pytest.mark.usefixtures("restore_db_per_function")
class TestTokenAuth:
@staticmethod
def login(api_client: ApiClient, username: str) -> models.Token:
auth, _ = api_client.auth_api.create_login(
models.LoginSerializerExRequest(username=username, password=USER_PASS)
)
# Remove automatically managed cookies
api_client.cookies.pop("sessionid")
api_client.cookies.pop("csrftoken")
# Set up the token authentication
api_client.set_default_header("Authorization", "Token " + auth.key)
return auth
@classmethod
@contextmanager
def make_client(cls, username: str | None = None) -> Generator[ApiClient, None, None]:
with ApiClient(Configuration(host=BASE_URL)) as api_client:
if username:
cls.login(api_client, username)
yield api_client
def _test_can_auth(self, api_client: ApiClient, *, username: str, auth_key: str):
from cvat_sdk.api_client.rest import RESTClientObject
original_request = RESTClientObject.request
def patched_request(*args, **kwargs):
assert "sessionid" not in kwargs["headers"].get("Cookie", "")
assert "X-CSRFToken" not in kwargs["headers"]
assert kwargs["headers"]["Authorization"] == "Token " + auth_key
return original_request(api_client.rest_client, *args, **kwargs)
with mock.patch.object(
api_client.rest_client, "request", side_effect=patched_request
) as mock_request:
user, response = api_client.users_api.retrieve_self()
mock_request.assert_called_once()
assert response.status == HTTPStatus.OK
assert user.username == username
def test_can_use_token_auth(self, admin_user: str):
username = admin_user
with self.make_client() as api_client:
auth = self.login(api_client, username=username)
assert auth.key
self._test_can_auth(api_client, username=username, auth_key=auth.key)
def test_can_use_token_auth_from_config(self, admin_user: str):
username = admin_user
with self.make_client() as api_client:
auth = self.login(api_client, username=username)
config = Configuration(
host=BASE_URL,
api_key={
"tokenAuth": auth.key,
},
)
with ApiClient(config) as api_client:
self._test_can_auth(api_client, username=username, auth_key=auth.key)
def test_can_logout(self, admin_user: str):
with self.make_client(admin_user) as api_client:
_, response = api_client.auth_api.create_logout()
assert response.status == HTTPStatus.OK
_, response = api_client.users_api.retrieve_self(
_parse_response=False, _check_status=False
)
assert response.status == HTTPStatus.UNAUTHORIZED
@pytest.mark.usefixtures("restore_db_per_function")
class TestSessionAuth:
@staticmethod
def login(api_client: ApiClient, username: str) -> models.Token:
auth, _ = api_client.auth_api.create_login(
models.LoginSerializerExRequest(username=username, password=USER_PASS)
)
# Set up the session and CSRF authentication
api_client.set_default_header("Origin", api_client.build_origin_header())
api_client.set_default_header("X-CSRFToken", api_client.cookies["csrftoken"].value)
return auth
@classmethod
@contextmanager
def make_client(cls, username: str | None = None) -> Generator[ApiClient, None, None]:
with ApiClient(Configuration(host=BASE_URL)) as api_client:
if username:
cls.login(api_client, username)
yield api_client
def _test_can_use_auth(self, api_client: ApiClient, *, username: str):
from cvat_sdk.api_client.rest import RESTClientObject
original_request = RESTClientObject.request
def patched_request(*args, **kwargs):
assert "sessionid" in kwargs["headers"].get("Cookie", "")
assert "csrftoken" in kwargs["headers"].get("Cookie", "")
assert "X-CSRFToken" in kwargs["headers"]
assert "Origin" in kwargs["headers"]
assert "Authorization" not in kwargs["headers"]
return original_request(api_client.rest_client, *args, **kwargs)
with mock.patch.object(
api_client.rest_client, "request", side_effect=patched_request
) as mock_request:
user, response = api_client.users_api.retrieve_self()
mock_request.assert_called_once()
assert response.status == HTTPStatus.OK
assert user.username == username
# Check CSRF authentication in an "unsafe" request
api_client.users_api.partial_update(
user.id, patched_user_request=models.PatchedUserRequest(first_name="Newname")
)
def test_can_use_session_auth(self, admin_user: str):
username = admin_user
with self.make_client(username) as api_client:
self._test_can_use_auth(api_client, username=username)
def test_can_use_session_auth_from_config(self, admin_user: str):
username = admin_user
with self.make_client(username) as api_client:
config = Configuration(
host=BASE_URL,
api_key={
"sessionAuth": api_client.cookies["sessionid"].value,
"csrfAuth": api_client.cookies["csrftoken"].value,
},
)
with ApiClient(config) as api_client:
self._test_can_use_auth(api_client, username=username)
def test_can_logout(self, admin_user: str):
with self.make_client(admin_user) as api_client:
_, response = api_client.auth_api.create_logout()
assert response.status == HTTPStatus.OK
_, response = api_client.users_api.retrieve_self(
_parse_response=False, _check_status=False
)
assert response.status == HTTPStatus.UNAUTHORIZED
@pytest.mark.usefixtures("restore_db_per_function")
class TestAccessTokenAuth:
@classmethod
@contextmanager
def make_client(cls, *, token: str | None = None) -> Generator[ApiClient, None, None]:
with ApiClient(Configuration(host=BASE_URL)) as api_client:
if token:
api_client.configuration.access_token = token
yield api_client
def _test_can_use_auth(self, api_client: ApiClient, *, username: str, access_token: str):
from cvat_sdk.api_client.rest import RESTClientObject
original_request = RESTClientObject.request
def patched_request(*args, **kwargs):
assert "sessionid" not in kwargs["headers"].get("Cookie", "")
assert "X-CSRFToken" not in kwargs["headers"]
assert kwargs["headers"]["Authorization"] == "Bearer " + access_token
return original_request(api_client.rest_client, *args, **kwargs)
with mock.patch.object(
api_client.rest_client, "request", side_effect=patched_request
) as mock_request:
user, response = api_client.users_api.retrieve_self()
mock_request.assert_called_once()
assert response.status == HTTPStatus.OK
assert user.username == username
def test_can_use_token_auth(self, admin_user: str, access_tokens_by_username):
token = access_tokens_by_username[admin_user][0]["private_key"]
with self.make_client(token=token) as api_client:
self._test_can_use_auth(api_client, username=admin_user, access_token=token)
def test_logout_is_not_an_error(self, admin_user: str, access_tokens_by_username):
token = access_tokens_by_username[admin_user][0]["private_key"]
with ApiClient(Configuration(host=BASE_URL)) as session_api_client:
session_api_client.auth_api.create_login(
login_serializer_ex_request=models.LoginSerializerExRequest(
username=admin_user, password=USER_PASS
)
)
session_api_client.set_default_header(
"Origin", session_api_client.build_origin_header()
)
session_api_client.set_default_header(
"X-CSRFToken", session_api_client.cookies["csrftoken"].value
)
with self.make_client(token=token) as token_api_client:
# It must be a noop call in the case of API access token auth
_, response = token_api_client.auth_api.create_logout()
assert response.status == HTTPStatus.OK
# the credentials are still in the client and can be used
assert token_api_client.configuration.access_token == token
self._test_can_use_auth(token_api_client, username=admin_user, access_token=token)
# Other sessions must not be affected by the logout
session_api_client.users_api.retrieve_self()
@pytest.mark.usefixtures("restore_db_per_function")
class TestCredentialsManagement:
def test_can_register(self):
username = "newuser"
email = "123@456.com"
with ApiClient(Configuration(host=BASE_URL)) as api_client:
user, response = api_client.auth_api.create_register(
models.RegisterSerializerExRequest(
username=username, password1=USER_PASS, password2=USER_PASS, email=email
)
)
assert response.status == HTTPStatus.CREATED
assert user.username == username
with make_api_client(username) as api_client:
user, response = api_client.users_api.retrieve_self()
assert response.status == HTTPStatus.OK
assert user.username == username
assert user.email == email
def test_can_change_password(self, admin_user: str):
username = admin_user
new_pass = "5w4knrqaW#$@gewa"
with make_api_client(username) as api_client:
info, response = api_client.auth_api.create_password_change(
models.PasswordChangeRequest(
old_password=USER_PASS, new_password1=new_pass, new_password2=new_pass
)
)
assert response.status == HTTPStatus.OK
assert info.detail == "New password has been saved."
_, response = api_client.users_api.retrieve_self(
_parse_response=False, _check_status=False
)
assert response.status == HTTPStatus.UNAUTHORIZED
api_client.configuration.password = new_pass
user, response = api_client.users_api.retrieve_self()
assert response.status == HTTPStatus.OK
assert user.username == username
def test_can_report_weak_password(self, admin_user: str):
username = admin_user
new_pass = "password"
with make_api_client(username) as api_client:
_, response = api_client.auth_api.create_password_change(
models.PasswordChangeRequest(
old_password=USER_PASS, new_password1=new_pass, new_password2=new_pass
),
_parse_response=False,
_check_status=False,
)
assert response.status == HTTPStatus.BAD_REQUEST
assert json.loads(response.data) == {"new_password2": ["This password is too common."]}
def test_can_report_mismatching_passwords(self, admin_user: str):
username = admin_user
with make_api_client(username) as api_client:
_, response = api_client.auth_api.create_password_change(
models.PasswordChangeRequest(
old_password=USER_PASS, new_password1="3j4tb13/T$#", new_password2="q#@$n34g5"
),
_parse_response=False,
_check_status=False,
)
assert response.status == HTTPStatus.BAD_REQUEST
assert json.loads(response.data) == {
"new_password2": ["The two password fields didnt match."]
}
@@ -0,0 +1,42 @@
# Copyright (C) 2022 Intel Corporation
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import re
from http import HTTPStatus
from shared.utils.config import server_get
class TestCachePolicy:
@staticmethod
def _get_js_bundle_url(response):
match = re.search(r'<script.* src="(/assets/cvat-ui.\w+.min.js)".*></script>', response)
if match:
return match.group(1)
def _test_cache_policy_enabled(self, response):
assert response.status_code == HTTPStatus.OK
assert (
"public" in response.headers["Cache-Control"]
and "max-age" in response.headers["Cache-Control"]
)
def _test_cache_policy_disabled(self, response):
assert response.status_code == HTTPStatus.OK
assert "no-cache" in response.headers["Cache-Control"]
def test_index_not_cached(self, find_users):
user = find_users(privilege="user")[0]["username"]
index_page_response = server_get(user, "/")
self._test_cache_policy_disabled(index_page_response)
def test_asset_cached(self, find_users):
user = find_users(privilege="user")[0]["username"]
index_page_response = server_get(user, "/")
js_asset_url = self._get_js_bundle_url(index_page_response.content.decode("utf-8"))
js_asset_response = server_get(user, js_asset_url)
self._test_cache_policy_enabled(js_asset_response)
@@ -0,0 +1,73 @@
# Copyright (C) 2021-2022 Intel Corporation
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import json
from pathlib import Path
import pytest
from deepdiff import DeepDiff
from shared.utils import config
@pytest.mark.usefixtures("restore_db_per_class")
class TestGetResources:
@pytest.mark.parametrize("path", config.ASSETS_DIR.glob("*.json"))
def test_check_objects_integrity(self, path: Path):
with open(path) as f:
endpoint = path.stem
if endpoint in [
"quality_settings",
"quality_reports",
"quality_conflicts",
"consensus_settings",
]:
endpoint = "/".join(endpoint.split("_"))
elif endpoint == "access_tokens":
endpoint = "auth/access_tokens"
if endpoint == "annotations":
objects = json.load(f)
for jid, annotations in objects["job"].items():
response = config.get_method("admin1", f"jobs/{jid}/annotations").json()
assert (
DeepDiff(
annotations,
response,
ignore_order=True,
exclude_paths="root['version']",
)
== {}
)
elif endpoint == "auth/access_tokens":
objects = json.load(f)
assert set(objects) == {"user"}
for username, tokens in objects["user"].items():
response = config.get_method(
username, "auth/access_tokens", page_size=100, sort="id"
).json()["results"]
assert (
DeepDiff(
tokens,
response,
ignore_order=True,
)
== {}
)
else:
response = config.get_method("admin1", endpoint, page_size="all")
json_objs = json.load(f)
resp_objs = response.json()
assert (
DeepDiff(
json_objs,
resp_objs,
ignore_order=True,
exclude_regex_paths=r"root\['results'\]\[\d+\]\['last_login'\]",
)
== {}
)
@@ -0,0 +1,767 @@
# Copyright (C) 2022 Intel Corporation
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import io
import json
from functools import partial
from http import HTTPStatus
from typing import Any
import pytest
from cvat_sdk.api_client import ApiClient, models
from cvat_sdk.api_client.api_client import Endpoint
from cvat_sdk.api_client.model.file_info import FileInfo
from deepdiff import DeepDiff
from PIL import Image
from shared.utils.config import get_method, make_api_client
from shared.utils.s3 import make_client as make_s3_client
from .utils import CollectionSimpleFilterTestBase
# https://docs.pytest.org/en/7.1.x/example/markers.html#marking-whole-classes-or-modules
pytestmark = [pytest.mark.with_external_services]
@pytest.mark.usefixtures("restore_db_per_class")
class TestGetCloudStorage:
def _test_can_see(self, user, storage_id, data):
with make_api_client(user) as api_client:
_, response = api_client.cloudstorages_api.retrieve(
id=storage_id,
_parse_response=False,
_check_status=False,
)
assert response.status == HTTPStatus.OK
response_json = json.loads(response.data)
assert (
DeepDiff(
data, response_json, ignore_order=True, exclude_paths="root['updated_date']"
)
== {}
)
def _test_cannot_see(self, user, storage_id):
with make_api_client(user) as api_client:
_, response = api_client.cloudstorages_api.retrieve(
id=storage_id,
_parse_response=False,
_check_status=False,
)
assert response.status == HTTPStatus.FORBIDDEN
@pytest.mark.parametrize("storage_id", [1])
@pytest.mark.parametrize(
"group, is_owner, is_allow",
[
("admin", False, True),
("user", True, True),
],
)
def test_sandbox_user_get_cloud_storage(
self, storage_id, group, is_owner, is_allow, users, cloud_storages
):
cloud_storage = cloud_storages[storage_id]
username = (
cloud_storage["owner"]["username"]
if is_owner
else next(
u for u in users if group in u["groups"] and u["id"] != cloud_storage["owner"]["id"]
)["username"]
)
if is_allow:
self._test_can_see(username, storage_id, cloud_storage)
else:
self._test_cannot_see(username, storage_id)
@pytest.mark.parametrize("org_id", [2])
@pytest.mark.parametrize("storage_id", [2])
@pytest.mark.parametrize(
"role, is_owner, is_allow",
[
("worker", True, True),
("supervisor", False, True),
("worker", False, False),
],
)
def test_org_user_get_cloud_storage(
self, org_id, storage_id, role, is_owner, is_allow, find_users, cloud_storages
):
cloud_storage = cloud_storages[storage_id]
username = (
cloud_storage["owner"]["username"]
if is_owner
else next(
u
for u in find_users(role=role, org=org_id)
if u["id"] != cloud_storage["owner"]["id"]
)["username"]
)
if is_allow:
self._test_can_see(username, storage_id, cloud_storage)
else:
self._test_cannot_see(username, storage_id)
def test_can_remove_owner_and_fetch_with_sdk(self, admin_user, cloud_storages):
# test for API schema regressions
source_storage = next(
s for s in cloud_storages if s.get("owner") and s["owner"]["username"] != admin_user
).copy()
with make_api_client(admin_user) as api_client:
api_client.users_api.destroy(source_storage["owner"]["id"])
_, response = api_client.cloudstorages_api.retrieve(source_storage["id"])
fetched_storage = json.loads(response.data)
source_storage["owner"] = None
assert DeepDiff(source_storage, fetched_storage, ignore_order=True) == {}
class TestCloudStoragesListFilters(CollectionSimpleFilterTestBase):
field_lookups = {
"owner": ["owner", "username"],
"name": ["display_name"],
}
@pytest.fixture(autouse=True)
def setup(self, restore_db_per_class, admin_user, cloud_storages):
self.user = admin_user
self.samples = cloud_storages
def _get_endpoint(self, api_client: ApiClient) -> Endpoint:
return api_client.cloudstorages_api.list_endpoint
@pytest.mark.parametrize(
"field",
("provider_type", "name", "resource", "credentials_type", "owner"),
)
def test_can_use_simple_filter_for_object_list(self, field):
return super()._test_can_use_simple_filter_for_object_list(field)
@pytest.mark.usefixtures("restore_db_per_function")
class TestPostCloudStorage:
_SPEC = {
"provider_type": "AWS_S3_BUCKET",
"resource": "test",
"display_name": "Bucket",
"credentials_type": "KEY_SECRET_KEY_PAIR",
"key": "minio_access_key",
"secret_key": "minio_secret_key",
"specific_attributes": "endpoint_url=http://minio:9000",
"description": "Some description",
"manifests": ["images_with_manifest/manifest.jsonl"],
}
_EXCLUDE_PATHS = [
f"root['{extra_field}']"
for extra_field in {
# unchanged fields
"created_date",
"id",
"organization",
"owner",
"updated_date",
# credentials that server doesn't return
"key",
"secret_key",
}
]
def _test_can_create(self, user, spec, **kwargs):
with make_api_client(user) as api_client:
_, response = api_client.cloudstorages_api.create(
models.CloudStorageWriteRequest(**spec),
**kwargs,
_parse_response=False,
_check_status=False,
)
assert response.status == HTTPStatus.CREATED
response_json = json.loads(response.data)
assert (
DeepDiff(
self._SPEC, response_json, ignore_order=True, exclude_paths=self._EXCLUDE_PATHS
)
== {}
)
def _test_cannot_create(self, user, spec, **kwargs):
with make_api_client(user) as api_client:
_, response = api_client.cloudstorages_api.create(
models.CloudStorageWriteRequest(**spec),
**kwargs,
_parse_response=False,
_check_status=False,
)
assert response.status == HTTPStatus.FORBIDDEN
@pytest.mark.parametrize("group, is_allow", [("user", True), ("worker", False)])
def test_sandbox_user_create_cloud_storage(self, group, is_allow, users):
org = ""
username = [u for u in users if group in u["groups"]][0]["username"]
if is_allow:
self._test_can_create(username, self._SPEC, org=org)
else:
self._test_cannot_create(username, self._SPEC, org=org)
@pytest.mark.parametrize("org_id", [2])
@pytest.mark.parametrize(
"role, is_allow",
[
("owner", True),
("maintainer", True),
("worker", False),
("supervisor", False),
],
)
def test_org_user_create_cloud_storage(self, org_id, role, is_allow, find_users):
username = find_users(role=role, org=org_id)[0]["username"]
if is_allow:
self._test_can_create(username, self._SPEC, org_id=org_id)
else:
self._test_cannot_create(username, self._SPEC, org_id=org_id)
@pytest.mark.usefixtures("restore_db_per_function")
class TestPatchCloudStorage:
_SPEC = {
"display_name": "New display name",
"description": "New description",
"manifests": [
"images_with_manifest/manifest_1.jsonl",
"images_with_manifest/manifest_2.jsonl",
],
}
_PRIVATE_BUCKET_SPEC = {
"display_name": "New display name",
"description": "New description",
"manifests": [
"sub/images_with_manifest/manifest_1.jsonl",
"sub/images_with_manifest/manifest_2.jsonl",
],
}
_EXCLUDE_PATHS = [
f"root['{extra_field}']"
for extra_field in {
# unchanged fields
"created_date",
"credentials_type",
"id",
"organization",
"owner",
"provider_type",
"resource",
"specific_attributes",
"updated_date",
}
]
def _test_can_update(self, user, storage_id, spec):
with make_api_client(user) as api_client:
_, response = api_client.cloudstorages_api.partial_update(
id=storage_id,
patched_cloud_storage_write_request=models.PatchedCloudStorageWriteRequest(**spec),
_parse_response=False,
_check_status=False,
)
assert response.status == HTTPStatus.OK
response_json = json.loads(response.data)
assert (
DeepDiff(spec, response_json, ignore_order=True, exclude_paths=self._EXCLUDE_PATHS)
== {}
)
def _test_cannot_update(self, user, storage_id, spec):
with make_api_client(user) as api_client:
_, response = api_client.cloudstorages_api.partial_update(
id=storage_id,
patched_cloud_storage_write_request=models.PatchedCloudStorageWriteRequest(**spec),
_parse_response=False,
_check_status=False,
)
assert response.status == HTTPStatus.FORBIDDEN
@pytest.mark.parametrize("storage_id", [1])
@pytest.mark.parametrize(
"group, is_owner, is_allow",
[
("admin", False, True),
("worker", True, True),
],
)
def test_sandbox_user_update_cloud_storage(
self, storage_id, group, is_owner, is_allow, users, cloud_storages
):
cloud_storage = cloud_storages[storage_id]
username = (
cloud_storage["owner"]["username"]
if is_owner
else next(
u for u in users if group in u["groups"] and u["id"] != cloud_storage["owner"]["id"]
)["username"]
)
if is_allow:
self._test_can_update(username, storage_id, self._SPEC)
else:
self._test_cannot_update(username, storage_id, self._SPEC)
@pytest.mark.parametrize("org_id", [2])
@pytest.mark.parametrize("storage_id", [2])
@pytest.mark.parametrize(
"role, is_owner, is_allow",
[
("worker", True, True),
("maintainer", False, True),
("supervisor", False, False),
],
)
def test_org_user_update_cloud_storage(
self, org_id, storage_id, role, is_owner, is_allow, find_users, cloud_storages
):
cloud_storage = cloud_storages[storage_id]
username = (
cloud_storage["owner"]["username"]
if is_owner
else next(
u
for u in find_users(role=role, org=org_id)
if u["id"] != cloud_storage["owner"]["id"]
)["username"]
)
if is_allow:
self._test_can_update(username, storage_id, self._PRIVATE_BUCKET_SPEC)
else:
self._test_cannot_update(username, storage_id, self._PRIVATE_BUCKET_SPEC)
@pytest.mark.usefixtures("restore_db_per_class")
class TestGetCloudStoragePreview:
def _test_can_see(self, user, storage_id):
with make_api_client(user) as api_client:
_, response = api_client.cloudstorages_api.retrieve_preview(
id=storage_id,
_parse_response=False,
_check_status=False,
)
assert response.status == HTTPStatus.OK
width, height = Image.open(io.BytesIO(response.data)).size
assert width > 0 and height > 0
def _test_cannot_see(self, user, storage_id):
with make_api_client(user) as api_client:
_, response = api_client.cloudstorages_api.retrieve_preview(
id=storage_id,
_parse_response=False,
_check_status=False,
)
assert response.status == HTTPStatus.FORBIDDEN
@pytest.mark.parametrize("storage_id", [1])
@pytest.mark.parametrize(
"group, is_owner, is_allow",
[
("admin", False, True),
("user", True, True),
],
)
def test_sandbox_user_get_cloud_storage_preview(
self, storage_id, group, is_owner, is_allow, users, cloud_storages
):
cloud_storage = cloud_storages[storage_id]
username = (
cloud_storage["owner"]["username"]
if is_owner
else next(
u for u in users if group in u["groups"] and u["id"] != cloud_storage["owner"]["id"]
)["username"]
)
if is_allow:
self._test_can_see(username, storage_id)
else:
self._test_cannot_see(username, storage_id)
@pytest.mark.parametrize("org_id", [2])
@pytest.mark.parametrize("storage_id", [2])
@pytest.mark.parametrize(
"role, is_owner, is_allow",
[
("worker", True, True),
("supervisor", False, True),
("worker", False, False),
],
)
def test_org_user_get_cloud_storage_preview(
self, org_id, storage_id, role, is_owner, is_allow, find_users, cloud_storages
):
cloud_storage = cloud_storages[storage_id]
username = (
cloud_storage["owner"]["username"]
if is_owner
else next(
u
for u in find_users(role=role, org=org_id)
if u["id"] != cloud_storage["owner"]["id"]
)["username"]
)
if is_allow:
self._test_can_see(username, storage_id)
else:
self._test_cannot_see(username, storage_id)
@pytest.mark.usefixtures("restore_db_per_function")
class TestGetCloudStorageContent:
USER = "admin1"
def _test_get_cloud_storage_content(
self,
cloud_storage_id: int,
manifest: str | None = None,
**kwargs,
):
with make_api_client(self.USER) as api_client:
content_kwargs = {"manifest_path": manifest} if manifest else {}
for item in ["next_token", "prefix", "page_size"]:
if item_value := kwargs.get(item):
content_kwargs[item] = item_value
data, _ = api_client.cloudstorages_api.retrieve_content_v2(
cloud_storage_id, **content_kwargs
)
return data
@pytest.mark.parametrize("cloud_storage_id", [2])
@pytest.mark.parametrize(
"manifest, prefix, default_bucket_prefix, page_size, expected_content",
[
(
# [v2] list root of bucket with based on manifest
"sub/images_with_manifest/manifest.jsonl",
None,
None,
None,
[FileInfo(mime_type="DIR", name="sub", type="DIR")],
),
(
# [v2] search by some prefix in bucket content based on manifest
"sub/images_with_manifest/manifest.jsonl",
"sub/images_with_manifest/image_case_65_1",
None,
None,
[
FileInfo(mime_type="image", name="image_case_65_1.png", type="REG"),
],
),
(
# [v2] list nested directory of bucket content based on manifest
"sub/images_with_manifest/manifest.jsonl",
"sub/images_with_manifest/",
None,
None,
[
FileInfo(mime_type="image", name="image_case_65_1.png", type="REG"),
FileInfo(mime_type="image", name="image_case_65_2.png", type="REG"),
],
),
(
# [v2] list root of real bucket content
None,
None,
None,
None,
[FileInfo(mime_type="DIR", name="sub", type="DIR")],
),
(
# [v2] list nested directory of real bucket content
None,
"sub/images_with_manifest/",
None,
2,
[
FileInfo(mime_type="unknown", name="demo_manifest.jsonl", type="REG"),
FileInfo(mime_type="image", name="image_case_65_1.png", type="REG"),
],
),
(
None,
"/sub/images_with_manifest/", # cover case: API is identical to share point API
None,
None,
[
FileInfo(mime_type="unknown", name="demo_manifest.jsonl", type="REG"),
FileInfo(mime_type="image", name="image_case_65_1.png", type="REG"),
FileInfo(mime_type="image", name="image_case_65_2.png", type="REG"),
FileInfo(mime_type="unknown", name="manifest.jsonl", type="REG"),
FileInfo(mime_type="unknown", name="manifest_1.jsonl", type="REG"),
FileInfo(mime_type="unknown", name="manifest_2.jsonl", type="REG"),
],
),
(
# [v2] list bucket content based on manifest when default bucket prefix is set to directory
"sub/images_with_manifest/manifest.jsonl",
None,
"sub/images_with_manifest/",
None,
[
FileInfo(mime_type="image", name="image_case_65_1.png", type="REG"),
FileInfo(mime_type="image", name="image_case_65_2.png", type="REG"),
],
),
(
# [v2] list bucket content based on manifest when default bucket prefix
# is set to template from which the files should start
"sub/images_with_manifest/manifest.jsonl",
None,
"sub/images_with_manifest/image_case_65_1",
None,
[
FileInfo(mime_type="image", name="image_case_65_1.png", type="REG"),
],
),
(
# [v2] list bucket content based on manifest when specified prefix is stricter than default bucket prefix
"sub/images_with_manifest/manifest.jsonl",
"sub/images_with_manifest/image_case_65_1",
"sub/images_with_manifest/image_case",
None,
[
FileInfo(mime_type="image", name="image_case_65_1.png", type="REG"),
],
),
(
# [v2] list bucket content based on manifest when default bucket prefix is stricter than specified prefix
"sub/images_with_manifest/manifest.jsonl",
"sub/images_with_manifest/image_case",
"sub/images_with_manifest/image_case_65_1",
None,
[
FileInfo(mime_type="image", name="image_case_65_1.png", type="REG"),
],
),
(
# [v2] list bucket content based on manifest when default bucket prefix and specified prefix have no intersection
"sub/images_with_manifest/manifest.jsonl",
"sub/images_with_manifest/image_case_65_1",
"sub/images_with_manifest/image_case_65_2",
None,
[],
),
(
# [v2] list bucket content based on manifest when default bucket prefix contains dirs and prefix starts with it
"sub/images_with_manifest/manifest.jsonl",
"s",
"sub/images_with_manifest/",
None,
[
FileInfo(mime_type="DIR", name="sub", type="DIR"),
],
),
(
# [v2] list real bucket content when default bucket prefix is set to directory
None,
None,
"sub/images_with_manifest/",
None,
[
FileInfo(mime_type="unknown", name="demo_manifest.jsonl", type="REG"),
FileInfo(mime_type="image", name="image_case_65_1.png", type="REG"),
FileInfo(mime_type="image", name="image_case_65_2.png", type="REG"),
FileInfo(mime_type="unknown", name="manifest.jsonl", type="REG"),
FileInfo(mime_type="unknown", name="manifest_1.jsonl", type="REG"),
FileInfo(mime_type="unknown", name="manifest_2.jsonl", type="REG"),
],
),
(
# [v2] list real bucket content when default bucket prefix
# is set to template from which the files should start
None,
None,
"sub/images_with_manifest/demo",
None,
[
FileInfo(mime_type="unknown", name="demo_manifest.jsonl", type="REG"),
],
),
(
# [v2] list real bucket content when specified prefix is stricter than default bucket prefix
None,
"sub/images_with_manifest/image_case_65_1",
"sub/images_with_manifest/image_case",
None,
[
FileInfo(mime_type="image", name="image_case_65_1.png", type="REG"),
],
),
(
# [v2] list real bucket content when default bucket prefix is stricter than specified prefix
None,
"sub/images_with_manifest/image_case",
"sub/images_with_manifest/image_case_65_1",
None,
[
FileInfo(mime_type="image", name="image_case_65_1.png", type="REG"),
],
),
(
# [v2] list real bucket content when default bucket prefix and specified prefix have no intersection
None,
"sub/images_with_manifest/image_case_65_1",
"sub/images_with_manifest/image_case_65_2",
None,
[],
),
(
# [v2] list real bucket content when default bucket prefix contains dirs and prefix starts with it
None,
"s",
"sub/images_with_manifest/",
None,
[
FileInfo(mime_type="DIR", name="sub", type="DIR"),
],
),
],
)
def test_get_cloud_storage_content(
self,
cloud_storage_id: int,
manifest: str | None,
prefix: str | None,
default_bucket_prefix: str | None,
page_size: int | None,
expected_content: Any | None,
cloud_storages,
):
if default_bucket_prefix:
cloud_storage = cloud_storages[cloud_storage_id]
with make_api_client(self.USER) as api_client:
_, response = api_client.cloudstorages_api.partial_update(
cloud_storage_id,
patched_cloud_storage_write_request={
"specific_attributes": f'{cloud_storage["specific_attributes"]}&prefix={default_bucket_prefix}'
},
)
assert response.status == HTTPStatus.OK
result = self._test_get_cloud_storage_content(
cloud_storage_id, manifest, prefix=prefix, page_size=page_size
)
if expected_content:
assert result["content"] == expected_content
if page_size:
assert len(result["content"]) <= page_size
@pytest.mark.parametrize("cloud_storage_id, prefix, page_size", [(2, "sub/", 2)])
def test_iterate_over_cloud_storage_content(
self, cloud_storage_id: int, prefix: str, page_size: int
):
expected_content = self._test_get_cloud_storage_content(cloud_storage_id, prefix=prefix)[
"content"
]
current_content = []
next_token = None
while True:
result = self._test_get_cloud_storage_content(
cloud_storage_id,
prefix=prefix,
page_size=page_size,
next_token=next_token,
)
content = result["content"]
assert len(content) <= page_size
current_content.extend(content)
next_token = result["next"]
if not next_token:
break
# Each page is currently sorted individually, so we have to resort after combining.
assert expected_content == sorted(current_content, key=lambda el: el["type"].value)
@pytest.mark.parametrize("cloud_storage_id", [2])
def test_can_get_storage_content_with_manually_created_dirs(
self,
cloud_storage_id: int,
request,
cloud_storages,
):
initial_content = self._test_get_cloud_storage_content(cloud_storage_id)["content"]
cs_name = cloud_storages[cloud_storage_id]["resource"]
s3_client = make_s3_client(bucket=cs_name)
new_directory = "manually_created_directory/"
# directory is 0 size object that has a name ending with a forward slash
s3_client.create_file(
filename=new_directory,
)
request.addfinalizer(
partial(
s3_client.remove_file,
filename=new_directory,
)
)
content = self._test_get_cloud_storage_content(
cloud_storage_id,
)["content"]
assert len(initial_content) + 1 == len(content)
assert any(
new_directory.strip("/") == x["name"] and "DIR" == str(x["type"]) for x in content
)
content = self._test_get_cloud_storage_content(
cloud_storage_id,
prefix=new_directory,
)["content"]
assert not len(content)
@pytest.mark.usefixtures("restore_db_per_class")
class TestListCloudStorages:
def _test_can_see_cloud_storages(self, user, data, **kwargs):
response = get_method(user, "cloudstorages", **kwargs)
assert response.status_code == HTTPStatus.OK
assert DeepDiff(data, response.json()["results"]) == {}
def test_admin_can_see_all_cloud_storages(self, cloud_storages):
self._test_can_see_cloud_storages("admin2", cloud_storages.raw, page_size="all")
@pytest.mark.parametrize("field_value, query_value", [(2, 2), (None, "")])
def test_can_filter_by_org_id(self, field_value, query_value, cloud_storages):
cloud_storages = filter(lambda i: i["organization"] == field_value, cloud_storages)
self._test_can_see_cloud_storages(
"admin2", list(cloud_storages), page_size="all", org_id=query_value
)
class TestCloudStorageStatus:
@pytest.mark.parametrize(
"cloud_storage_id, expected_response",
[
(3, "AVAILABLE"),
(4, "NOT_FOUND"),
],
)
def test_minio_connection_status(self, cloud_storage_id, expected_response, admin_user):
with make_api_client(admin_user) as api_client:
data, _ = api_client.cloudstorages_api.retrieve_status(cloud_storage_id)
assert data == expected_response
+734
View File
@@ -0,0 +1,734 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import json
from copy import deepcopy
from functools import partial
from http import HTTPStatus
from itertools import product
from typing import Any
import pytest
import urllib3
from cvat_sdk.api_client import exceptions, models
from cvat_sdk.api_client.api_client import ApiClient, Endpoint
from cvat_sdk.core.helpers import get_paginated_collection
from deepdiff import DeepDiff
from shared.utils.config import make_api_client
from .utils import (
CollectionSimpleFilterTestBase,
compare_annotations,
invite_user_to_org,
register_new_user,
wait_background_request,
)
class _PermissionTestBase:
def merge(
self,
*,
task_id: int | None = None,
job_id: int | None = None,
user: str,
raise_on_error: bool = True,
wait_result: bool = True,
) -> urllib3.HTTPResponse:
assert task_id is not None or job_id is not None
kwargs = {}
if task_id is not None:
kwargs["task_id"] = task_id
if job_id is not None:
kwargs["job_id"] = job_id
with make_api_client(user) as api_client:
_, response = api_client.consensus_api.create_merge(
consensus_merge_create_request=models.ConsensusMergeCreateRequest(**kwargs),
_parse_response=False,
_check_status=raise_on_error,
)
if not raise_on_error and response.status != HTTPStatus.ACCEPTED:
return response
assert response.status == HTTPStatus.ACCEPTED
if wait_result:
rq_id = json.loads(response.data)["rq_id"]
background_request, _ = wait_background_request(api_client, rq_id)
assert (
background_request.status.value
== models.RequestStatus.allowed_values[("value",)]["FINISHED"]
)
return response
def request_merge(
self,
*,
task_id: int | None = None,
job_id: int | None = None,
user: str,
) -> str:
response = self.merge(user=user, task_id=task_id, job_id=job_id, wait_result=False)
return json.loads(response.data)["rq_id"]
@pytest.fixture
def find_sandbox_task(self, tasks, jobs, users, is_task_staff):
def _find(
is_staff: bool, *, has_consensus_jobs: bool | None = None
) -> tuple[dict[str, Any], dict[str, Any]]:
task = next(
t
for t in tasks
if t["organization"] is None
and not users[t["owner"]["id"]]["is_superuser"]
and (
has_consensus_jobs is None
or has_consensus_jobs
== any(
j
for j in jobs
if j["task_id"] == t["id"] and j["type"] == "consensus_replica"
)
)
)
if is_staff:
user = task["owner"]
else:
user = next(u for u in users if not is_task_staff(u["id"], task["id"]))
return task, user
return _find
@pytest.fixture
def find_sandbox_task_with_consensus(self, find_sandbox_task):
return partial(find_sandbox_task, has_consensus_jobs=True)
@pytest.fixture
def find_org_task(
self, restore_db_per_function, tasks, jobs, users, is_org_member, is_task_staff, admin_user
):
def _find(
is_staff: bool, user_org_role: str, *, has_consensus_jobs: bool | None = None
) -> tuple[dict[str, Any], dict[str, Any]]:
for user in users:
if user["is_superuser"]:
continue
task = next(
(
t
for t in tasks
if t["organization"] is not None
and is_task_staff(user["id"], t["id"]) == is_staff
and is_org_member(user["id"], t["organization"], role=user_org_role)
and (
has_consensus_jobs is None
or has_consensus_jobs
== any(
j
for j in jobs
if j["task_id"] == t["id"] and j["type"] == "consensus_replica"
)
)
),
None,
)
if task is not None:
break
if not task:
task = next(
t
for t in tasks
if t["organization"] is not None
if has_consensus_jobs is None or has_consensus_jobs == t["consensus_enabled"]
)
user = next(
u
for u in users
if is_org_member(u["id"], task["organization"], role=user_org_role)
)
if is_staff:
with make_api_client(admin_user) as api_client:
api_client.tasks_api.partial_update(
task["id"],
patched_task_write_request=models.PatchedTaskWriteRequest(
assignee_id=user["id"]
),
)
return task, user
return _find
@pytest.fixture
def find_org_task_with_consensus(self, find_org_task):
return partial(find_org_task, has_consensus_jobs=True)
_default_sandbox_cases = ("is_staff, allow", [(True, True), (False, False)])
_default_org_cases = (
"org_role, is_staff, allow",
[
("owner", True, True),
("owner", False, True),
("maintainer", True, True),
("maintainer", False, True),
("supervisor", True, True),
("supervisor", False, False),
("worker", True, True),
("worker", False, False),
],
)
_default_org_roles = ("owner", "maintainer", "supervisor", "worker")
@pytest.mark.usefixtures("restore_db_per_function")
@pytest.mark.usefixtures("restore_redis_inmem_per_function")
class TestPostConsensusMerge(_PermissionTestBase):
def test_can_merge_task_with_consensus_jobs(self, admin_user, tasks):
task_id = next(t["id"] for t in tasks if t["consensus_enabled"])
self.merge(user=admin_user, task_id=task_id)
def test_can_merge_consensus_job(self, admin_user, jobs):
job_id = next(
j["id"] for j in jobs if j["type"] == "annotation" and j["consensus_replicas"] > 0
)
self.merge(user=admin_user, job_id=job_id)
def test_cannot_merge_task_without_consensus_jobs(self, admin_user, tasks):
task_id = next(t["id"] for t in tasks if not t["consensus_enabled"])
with pytest.raises(exceptions.ApiException) as capture:
self.merge(user=admin_user, task_id=task_id)
assert "Consensus is not enabled in this task" in capture.value.body
def test_cannot_merge_task_without_mergeable_parent_jobs(self, admin_user, tasks, jobs):
task_id = next(t["id"] for t in tasks if t["consensus_enabled"])
for j in jobs:
if (j["stage"] != "annotation" or j["state"] != "new") and (
j["task_id"] == task_id and j["type"] in ("annotation", "consensus_replica")
):
with make_api_client(admin_user) as api_client:
api_client.jobs_api.partial_update(
j["id"],
patched_job_write_request=models.PatchedJobWriteRequest(
state="new", stage="annotation"
),
)
with pytest.raises(exceptions.ApiException) as capture:
self.merge(user=admin_user, task_id=task_id)
assert "No annotation jobs in the annotation stage" in capture.value.body
def test_cannot_merge_replica_job(self, admin_user, tasks, jobs):
job_id = next(
j["id"]
for j in jobs
if j["type"] == "consensus_replica"
if tasks.map[j["task_id"]]["consensus_enabled"]
)
with pytest.raises(exceptions.ApiException) as capture:
self.merge(user=admin_user, job_id=job_id)
assert "No annotated consensus jobs found for parent job" in capture.value.body
def _test_merge_200(self, user: str, *, task_id: int | None = None, job_id: int | None = None):
return self.merge(user=user, task_id=task_id, job_id=job_id)
def _test_merge_403(self, user: str, *, task_id: int | None = None, job_id: int | None = None):
response = self.merge(user=user, task_id=task_id, job_id=job_id, raise_on_error=False)
assert response.status == HTTPStatus.FORBIDDEN
return response
@pytest.mark.parametrize(*_PermissionTestBase._default_sandbox_cases)
def test_user_merge_in_sandbox_task(self, is_staff, allow, find_sandbox_task_with_consensus):
task, user = find_sandbox_task_with_consensus(is_staff)
if allow:
self._test_merge_200(user["username"], task_id=task["id"])
else:
self._test_merge_403(user["username"], task_id=task["id"])
@pytest.mark.parametrize(*_PermissionTestBase._default_org_cases)
def test_user_merge_in_org_task(
self,
find_org_task_with_consensus,
org_role,
is_staff,
allow,
):
task, user = find_org_task_with_consensus(is_staff, org_role)
if allow:
self._test_merge_200(user["username"], task_id=task["id"])
else:
self._test_merge_403(user["username"], task_id=task["id"])
# users with task:view rights can check status of report creation
def _test_check_merge_status(
self,
rq_id: str,
*,
staff_user: str,
another_user: str,
another_user_status: int = HTTPStatus.FORBIDDEN,
):
with make_api_client(another_user) as api_client:
_, response = api_client.requests_api.retrieve(
rq_id, _parse_response=False, _check_status=False
)
assert response.status == another_user_status
with make_api_client(staff_user) as api_client:
wait_background_request(api_client, rq_id)
def test_user_without_rights_cannot_check_status_of_merge_in_sandbox(
self,
find_sandbox_task_with_consensus,
users,
):
task, task_staff = find_sandbox_task_with_consensus(is_staff=True)
another_user = next(
u
for u in users
if (
u["id"] != task_staff["id"]
and not u["is_superuser"]
and u["id"] != task["owner"]["id"]
and u["id"] != (task["assignee"] or {}).get("id")
)
)
rq_id = self.request_merge(task_id=task["id"], user=task_staff["username"])
self._test_check_merge_status(
rq_id, staff_user=task_staff["username"], another_user=another_user["username"]
)
@pytest.mark.parametrize(
"same_org, role",
[
pair
for pair in product([True, False], _PermissionTestBase._default_org_roles)
if not (pair[0] and pair[1] in ["owner", "maintainer"])
],
)
def test_user_without_rights_cannot_check_status_of_merge_in_org(
self,
find_org_task_with_consensus,
same_org: bool,
role: str,
organizations,
):
task, task_staff = find_org_task_with_consensus(is_staff=True, user_org_role="supervisor")
# create a new user that passes the requirements
another_user = register_new_user(f"{same_org}{role}")
org_id = (
task["organization"]
if same_org
else next(o for o in organizations if o["id"] != task["organization"])["id"]
)
invite_user_to_org(another_user["email"], org_id, role)
rq_id = self.request_merge(task_id=task["id"], user=task_staff["username"])
self._test_check_merge_status(
rq_id, staff_user=task_staff["username"], another_user=another_user["username"]
)
@pytest.mark.parametrize(
"role",
# owner and maintainer have rights even without being assigned to a task
("supervisor", "worker"),
)
def test_task_assignee_can_check_status_of_merge_in_org(
self,
find_org_task_with_consensus,
role: str,
):
task, another_user = find_org_task_with_consensus(is_staff=False, user_org_role=role)
task_owner = task["owner"]
rq_id = self.request_merge(task_id=task["id"], user=task_owner["username"])
self._test_check_merge_status(
rq_id,
staff_user=task_owner["username"],
another_user=another_user["username"],
)
with make_api_client(task_owner["username"]) as api_client:
api_client.tasks_api.partial_update(
task["id"],
patched_task_write_request=models.PatchedTaskWriteRequest(
assignee_id=another_user["id"]
),
)
self._test_check_merge_status(
rq_id,
staff_user=task_owner["username"],
another_user=another_user["username"],
another_user_status=HTTPStatus.OK,
)
@pytest.mark.parametrize("is_sandbox", (True, False))
def test_admin_can_check_status_of_merge(
self,
find_org_task_with_consensus,
find_sandbox_task_with_consensus,
users,
is_sandbox: bool,
):
if is_sandbox:
task, task_staff = find_sandbox_task_with_consensus(is_staff=True)
else:
task, task_staff = find_org_task_with_consensus(is_staff=True, user_org_role="owner")
admin = next(
u
for u in users
if (
u["is_superuser"]
and u["id"] != task_staff["id"]
and u["id"] != task["owner"]["id"]
and u["id"] != (task["assignee"] or {}).get("id")
)
)
rq_id = self.request_merge(task_id=task["id"], user=task_staff["username"])
with make_api_client(admin["username"]) as api_client:
wait_background_request(api_client, rq_id)
class TestSimpleConsensusSettingsFilters(CollectionSimpleFilterTestBase):
@pytest.fixture(autouse=True)
def setup(self, admin_user, consensus_settings):
self.user = admin_user
self.samples = consensus_settings
def _get_endpoint(self, api_client: ApiClient) -> Endpoint:
return api_client.consensus_api.list_settings_endpoint
@pytest.mark.parametrize("field", ("task_id",))
def test_can_use_simple_filter_for_object_list(self, field):
return super()._test_can_use_simple_filter_for_object_list(field)
class TestListSettings(_PermissionTestBase):
def _test_list_settings_200(
self, user: str, task_id: int, *, expected_data: dict[str, Any] | None = None, **kwargs
):
with make_api_client(user) as api_client:
actual = get_paginated_collection(
api_client.consensus_api.list_settings_endpoint,
task_id=task_id,
**kwargs,
return_json=True,
)
if expected_data is not None:
assert DeepDiff(expected_data, actual, ignore_order=True) == {}
def _test_list_settings_403(self, user: str, task_id: int, **kwargs):
with make_api_client(user) as api_client:
_, response = api_client.consensus_api.list_settings(
task_id=task_id, **kwargs, _parse_response=False, _check_status=False
)
assert response.status == HTTPStatus.FORBIDDEN
return response
@pytest.mark.parametrize(*_PermissionTestBase._default_sandbox_cases)
def test_user_list_settings_in_sandbox_task(
self, is_staff, allow, find_sandbox_task_with_consensus, consensus_settings
):
task, user = find_sandbox_task_with_consensus(is_staff)
settings = next(s for s in consensus_settings if s["task_id"] == task["id"])
if allow:
self._test_list_settings_200(user["username"], task["id"], expected_data=[settings])
else:
self._test_list_settings_403(user["username"], task["id"])
@pytest.mark.parametrize(*_PermissionTestBase._default_org_cases)
def test_user_list_settings_in_org_task(
self,
consensus_settings,
find_org_task_with_consensus,
org_role: str,
is_staff,
allow: bool,
):
task, user = find_org_task_with_consensus(is_staff, org_role)
settings = next(s for s in consensus_settings if s["task_id"] == task["id"])
org_id = task["organization"]
if allow:
self._test_list_settings_200(
user["username"], task["id"], expected_data=[settings], org_id=org_id
)
else:
self._test_list_settings_403(user["username"], task["id"], org_id=org_id)
class TestGetSettings(_PermissionTestBase):
def _test_get_settings_200(
self, user: str, obj_id: int, *, expected_data: dict[str, Any] | None = None, **kwargs
):
with make_api_client(user) as api_client:
_, response = api_client.consensus_api.retrieve_settings(obj_id, **kwargs)
assert response.status == HTTPStatus.OK
if expected_data is not None:
assert DeepDiff(expected_data, json.loads(response.data), ignore_order=True) == {}
return response
def _test_get_settings_403(self, user: str, obj_id: int, **kwargs):
with make_api_client(user) as api_client:
_, response = api_client.consensus_api.retrieve_settings(
obj_id, **kwargs, _parse_response=False, _check_status=False
)
assert response.status == HTTPStatus.FORBIDDEN
return response
def test_can_get_settings(self, admin_user, consensus_settings):
settings = next(iter(consensus_settings))
self._test_get_settings_200(admin_user, settings["id"], expected_data=settings)
@pytest.mark.parametrize(*_PermissionTestBase._default_sandbox_cases)
def test_user_get_settings_in_sandbox_task(
self, is_staff, allow, find_sandbox_task_with_consensus, consensus_settings
):
task, user = find_sandbox_task_with_consensus(is_staff)
settings = next(s for s in consensus_settings if s["task_id"] == task["id"])
if allow:
self._test_get_settings_200(user["username"], settings["id"], expected_data=settings)
else:
self._test_get_settings_403(user["username"], settings["id"])
@pytest.mark.parametrize(*_PermissionTestBase._default_org_cases)
def test_user_get_settings_in_org_task(
self,
consensus_settings,
find_org_task_with_consensus,
org_role: str,
is_staff,
allow: bool,
):
task, user = find_org_task_with_consensus(is_staff, org_role)
settings = next(s for s in consensus_settings if s["task_id"] == task["id"])
if allow:
self._test_get_settings_200(user["username"], settings["id"], expected_data=settings)
else:
self._test_get_settings_403(user["username"], settings["id"])
@pytest.mark.usefixtures("restore_db_per_function")
class TestPatchSettings(_PermissionTestBase):
def _test_patch_settings_200(
self,
user: str,
obj_id: int,
data: dict[str, Any],
*,
expected_data: dict[str, Any] | None = None,
**kwargs,
):
with make_api_client(user) as api_client:
_, response = api_client.consensus_api.partial_update_settings(
obj_id, patched_consensus_settings_request=data, **kwargs
)
assert response.status == HTTPStatus.OK
if expected_data is not None:
assert DeepDiff(expected_data, json.loads(response.data), ignore_order=True) == {}
return response
def _test_patch_settings_403(self, user: str, obj_id: int, data: dict[str, Any], **kwargs):
with make_api_client(user) as api_client:
_, response = api_client.consensus_api.partial_update_settings(
obj_id,
patched_consensus_settings_request=data,
**kwargs,
_parse_response=False,
_check_status=False,
)
assert response.status == HTTPStatus.FORBIDDEN
return response
def _get_request_data(self, data: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
patched_data = deepcopy(data)
for field, value in data.items():
if isinstance(value, bool):
patched_data[field] = not value
elif isinstance(value, float):
patched_data[field] = 1 - value
expected_data = deepcopy(patched_data)
return patched_data, expected_data
def test_can_patch_settings(self, admin_user, consensus_settings):
settings = next(iter(consensus_settings))
data, expected_data = self._get_request_data(settings)
self._test_patch_settings_200(admin_user, settings["id"], data, expected_data=expected_data)
@pytest.mark.parametrize(*_PermissionTestBase._default_sandbox_cases)
def test_user_patch_settings_in_sandbox_task(
self, consensus_settings, find_sandbox_task_with_consensus, is_staff: bool, allow: bool
):
task, user = find_sandbox_task_with_consensus(is_staff)
settings = next(s for s in consensus_settings if s["task_id"] == task["id"])
request_data, expected_data = self._get_request_data(settings)
if allow:
self._test_patch_settings_200(
user["username"], settings["id"], request_data, expected_data=expected_data
)
else:
self._test_patch_settings_403(user["username"], settings["id"], request_data)
@pytest.mark.parametrize(
"org_role, is_staff, allow",
[
("owner", True, True),
("owner", False, True),
("maintainer", True, True),
("maintainer", False, True),
("supervisor", True, True),
("supervisor", False, False),
("worker", True, True),
("worker", False, False),
],
)
def test_user_patch_settings_in_org_task(
self,
consensus_settings,
find_org_task_with_consensus,
org_role: str,
is_staff: bool,
allow: bool,
):
task, user = find_org_task_with_consensus(is_staff, org_role)
settings = next(s for s in consensus_settings if s["task_id"] == task["id"])
request_data, expected_data = self._get_request_data(settings)
if allow:
self._test_patch_settings_200(
user["username"], settings["id"], request_data, expected_data=expected_data
)
else:
self._test_patch_settings_403(user["username"], settings["id"], request_data)
@pytest.mark.usefixtures("restore_db_per_function")
@pytest.mark.usefixtures("restore_redis_inmem_per_function")
class TestMerging(_PermissionTestBase):
@pytest.mark.parametrize("task_id", [31])
def test_merged_annotations_have_scores(self, admin_user, jobs, labels, task_id: int):
"""Test that merged annotations have score attributes based on agreement."""
task_labels = [l for l in labels if l.get("task_id") == task_id]
task_jobs = [j for j in jobs if j["task_id"] == task_id]
parent_job = next(
j for j in task_jobs if j["type"] == "annotation" if j["consensus_replicas"] > 0
)
replicas = [
j
for j in task_jobs
if j["type"] == "consensus_replica"
if j["parent_job_id"] == parent_job["id"]
]
with make_api_client(admin_user) as api_client:
api_client.tasks_api.destroy_annotations(task_id)
for replica in replicas:
api_client.jobs_api.destroy_annotations(replica["id"])
bbox_full_agreement = models.LabeledShapeRequest(
type="rectangle",
frame=parent_job["start_frame"],
label_id=task_labels[0]["id"],
points=[0, 0, 2, 2],
attributes=[
{"spec_id": attr["id"], "value": attr["default_value"]}
for attr in task_labels[0]["attributes"]
],
rotation=0,
z_order=0,
occluded=False,
outside=False,
group=0,
)
bbox_partial_agreement = models.LabeledShapeRequest(
type="rectangle",
frame=parent_job["start_frame"],
label_id=task_labels[0]["id"],
points=[4, 0, 6, 2],
rotation=0,
z_order=0,
occluded=False,
outside=False,
group=0,
)
for replica in replicas:
api_client.jobs_api.update_annotations(
replica["id"],
labeled_data_request=models.LabeledDataRequest(shapes=[bbox_full_agreement]),
)
api_client.jobs_api.update_annotations(
replicas[0]["id"],
labeled_data_request=models.LabeledDataRequest(
shapes=[bbox_full_agreement, bbox_partial_agreement]
),
)
self.merge(job_id=parent_job["id"], user=admin_user)
merged_annotations = json.loads(
api_client.jobs_api.retrieve_annotations(parent_job["id"])[1].data
)
assert compare_annotations(
merged_annotations,
{
"shapes": [
{**bbox_full_agreement.to_dict(), "source": "consensus", "score": 1.0},
{
**bbox_partial_agreement.to_dict(),
"source": "consensus",
"score": 1 / len(replicas),
},
]
},
ignore_source=False,
ignore_score=False,
)
+164
View File
@@ -0,0 +1,164 @@
# Copyright (C) 2021-2022 Intel Corporation
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import json
from http import HTTPStatus
import pytest
from cvat_sdk.api_client.api_client import ApiClient, Endpoint
from deepdiff import DeepDiff
from shared.utils.config import get_method, make_api_client, post_method
from .utils import CollectionSimpleFilterTestBase
class TestCreateInvitations:
ROLES = ["worker", "supervisor", "maintainer", "owner"]
@pytest.fixture(autouse=True)
def setup(self, restore_db_per_function, organizations, memberships, admin_user):
self.org_id = 2
self.owner = self.get_member("owner", memberships, self.org_id)
def _test_post_invitation_201(self, user, data, invitee, **kwargs):
response = post_method(user, "invitations", data, **kwargs)
assert response.status_code == HTTPStatus.CREATED, response.content
assert data["role"] == response.json()["role"]
assert invitee["id"] == response.json()["user"]["id"]
assert kwargs["org_id"] == response.json()["organization"]
def _test_post_invitation_403(self, user, data, **kwargs):
response = post_method(user, "invitations", data, **kwargs)
assert response.status_code == HTTPStatus.FORBIDDEN, response.content
assert "You do not have permission" in str(response.content)
@staticmethod
def get_non_member_users(memberships, users):
organization_users = set(m["user"]["id"] for m in memberships if m["user"] is not None)
non_member_users = [u for u in users if u["id"] not in organization_users]
return non_member_users
@staticmethod
def get_member(role, memberships, org_id):
member = [
m["user"]
for m in memberships
if m["role"] == role and m["organization"] == org_id and m["user"] is not None
][0]
return member
@pytest.mark.parametrize("org_role", ROLES)
@pytest.mark.parametrize("invitee_role", ROLES)
def test_create_invitation(self, organizations, memberships, users, org_role, invitee_role):
org_id = self.org_id
inviter_user = self.get_member(org_role, memberships, org_id)
invitee_user = self.get_non_member_users(memberships, users)[0]
if org_role in ["worker", "supervisor"]:
self._test_post_invitation_403(
inviter_user["username"],
{"role": invitee_role, "email": invitee_user["email"]},
org_id=org_id,
)
elif invitee_role in ["worker", "supervisor"]:
self._test_post_invitation_201(
inviter_user["username"],
{"role": invitee_role, "email": invitee_user["email"]},
invitee_user,
org_id=org_id,
)
elif invitee_role == "maintainer":
if org_role == "owner":
# only the owner can invite a maintainer
self._test_post_invitation_201(
inviter_user["username"],
{"role": invitee_role, "email": invitee_user["email"]},
invitee_user,
org_id=org_id,
)
else:
self._test_post_invitation_403(
inviter_user["username"],
{"role": invitee_role, "email": invitee_user["email"]},
org_id=org_id,
)
elif invitee_role == "owner":
# nobody can invite an owner
self._test_post_invitation_403(
inviter_user["username"],
{"role": invitee_role, "email": invitee_user["email"]},
org_id=org_id,
)
else:
assert False, "Unknown role"
class TestInvitationsListFilters(CollectionSimpleFilterTestBase):
field_lookups = {
"owner": ["owner", "username"],
"user_id": ["user", "id"],
}
@pytest.fixture(autouse=True)
def setup(self, restore_db_per_class, admin_user, invitations):
self.user = admin_user
self.samples = invitations
def _get_endpoint(self, api_client: ApiClient) -> Endpoint:
return api_client.invitations_api.list_endpoint
@pytest.mark.parametrize(
"field",
("owner", "user_id", "accepted"),
)
def test_can_use_simple_filter_for_object_list(self, field):
return super()._test_can_use_simple_filter_for_object_list(field)
@pytest.mark.usefixtures("restore_db_per_class")
class TestListInvitations:
def _test_can_see_invitations(self, user, data, **kwargs):
response = get_method(user, "invitations", **kwargs)
assert response.status_code == HTTPStatus.OK
assert DeepDiff(data, response.json()["results"]) == {}
def test_admin_can_see_all_invitations(self, invitations):
self._test_can_see_invitations("admin2", invitations.raw, page_size="all")
@pytest.mark.parametrize("field_value, query_value", [(1, 1), (None, "")])
def test_can_filter_by_org_id(self, field_value, query_value, invitations):
invitations = filter(lambda i: i["organization"] == field_value, invitations)
self._test_can_see_invitations(
"admin2", list(invitations), page_size="all", org_id=query_value
)
@pytest.mark.usefixtures("restore_db_per_class")
class TestGetInvitations:
def test_can_remove_owner_and_fetch_with_sdk(self, admin_user, invitations):
# test for API schema regressions
source_inv = next(
invitations
for invitations in invitations
if invitations.get("owner") and invitations["owner"]["username"] != admin_user
).copy()
with make_api_client(admin_user) as api_client:
api_client.users_api.destroy(source_inv["owner"]["id"])
_, response = api_client.invitations_api.retrieve(source_inv["key"])
fetched_inv = json.loads(response.data)
source_inv["owner"] = None
assert DeepDiff(source_inv, fetched_inv, ignore_order=True) == {}
+407
View File
@@ -0,0 +1,407 @@
# Copyright (C) 2022 Intel Corporation
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import json
from copy import deepcopy
from http import HTTPStatus
from typing import Any
import pytest
from cvat_sdk import models
from cvat_sdk.api_client.api_client import ApiClient, Endpoint
from deepdiff import DeepDiff
from shared.utils.config import get_method, make_api_client
from .utils import CollectionSimpleFilterTestBase
@pytest.mark.usefixtures("restore_db_per_function")
class TestPostIssues:
def _test_check_response(self, user, data, is_allow, **kwargs):
with make_api_client(user) as client:
_, response = client.issues_api.create(
models.IssueWriteRequest(**data),
**kwargs,
_parse_response=False,
_check_status=False,
)
if is_allow:
assert response.status == HTTPStatus.CREATED
response_json = json.loads(response.data)
assert user == response_json["owner"]["username"]
with make_api_client(user) as client:
comments, _ = client.comments_api.list(issue_id=response_json["id"])
assert data["message"] == comments.results[0].message
assert (
DeepDiff(
data,
response_json,
exclude_regex_paths=r"root\['created_date|updated_date|comments|id|owner|message'\]",
)
== {}
)
else:
assert response.status == HTTPStatus.FORBIDDEN
@pytest.mark.parametrize("org", [""])
@pytest.mark.parametrize(
"privilege, job_staff, is_allow",
[
("admin", True, True),
("admin", False, True),
("worker", True, True),
("worker", False, False),
("user", True, True),
("user", False, False),
],
)
def test_user_create_issue(
self, org, privilege, job_staff, is_allow, find_job_staff_user, find_users, jobs_by_org
):
users = find_users(privilege=privilege)
jobs = jobs_by_org[org]
username, jid = find_job_staff_user(jobs, users, job_staff)
(job,) = filter(lambda job: job["id"] == jid, jobs)
data = {
"assignee": None,
"comments": [],
"job": jid,
"frame": job["start_frame"],
"position": [
0.0,
0.0,
1.0,
1.0,
],
"resolved": False,
"message": "lorem ipsum",
}
self._test_check_response(username, data, is_allow)
@pytest.mark.parametrize("org", [2])
@pytest.mark.parametrize(
"role, job_staff, is_allow",
[
("maintainer", False, True),
("owner", False, True),
("supervisor", False, False),
("worker", False, False),
("maintainer", True, True),
("owner", True, True),
("supervisor", True, True),
("worker", True, True),
],
)
def test_member_create_issue(
self, org, role, job_staff, is_allow, find_job_staff_user, find_users, jobs_by_org, jobs
):
users = find_users(role=role, org=org)
username, jid = find_job_staff_user(jobs_by_org[org], users, job_staff)
job = jobs[jid]
data = {
"assignee": None,
"comments": [],
"job": jid,
"frame": job["start_frame"],
"position": [
0.0,
0.0,
1.0,
1.0,
],
"resolved": False,
"message": "lorem ipsum",
}
self._test_check_response(username, data, is_allow, org_id=org)
@pytest.mark.usefixtures("restore_db_per_function")
class TestPatchIssues:
def _test_check_response(self, user, issue_id, data, is_allow, **kwargs):
request_data, expected_response_data = data
with make_api_client(user) as client:
_, response = client.issues_api.partial_update(
issue_id,
patched_issue_write_request=models.PatchedIssueWriteRequest(**request_data),
**kwargs,
_parse_response=False,
_check_status=False,
)
if is_allow:
assert response.status == HTTPStatus.OK
assert (
DeepDiff(
expected_response_data,
json.loads(response.data),
exclude_regex_paths=r"root\['created_date|updated_date|comments|id|owner'\]",
)
== {}
)
else:
assert response.status == HTTPStatus.FORBIDDEN
@pytest.fixture(scope="class")
def request_and_response_data(self, issues, users):
def get_data(issue_id, *, username: str = None):
request_data = deepcopy(issues[issue_id])
request_data["resolved"] = not request_data["resolved"]
response_data = deepcopy(request_data)
request_data.pop("comments")
request_data.pop("updated_date")
request_data.pop("id")
request_data.pop("owner")
if username:
assignee = next(u for u in users if u["username"] == username)
request_data["assignee"] = assignee["id"]
response_data["assignee"] = {
k: assignee[k] for k in ["id", "username", "url", "first_name", "last_name"]
}
else:
request_data["assignee"] = None
return request_data, response_data
return get_data
@pytest.mark.parametrize("org", [""])
@pytest.mark.parametrize(
"privilege, issue_staff, issue_admin, is_allow",
[
("admin", True, None, True),
("admin", False, None, True),
("user", True, None, True),
("user", False, None, False),
("worker", False, True, True),
("worker", True, False, True),
("worker", False, False, False),
],
)
def test_user_update_issue(
self,
org,
privilege,
issue_staff,
issue_admin,
is_allow,
find_issue_staff_user,
find_users,
issues_by_org,
request_and_response_data,
):
users = find_users(privilege=privilege)
issues = issues_by_org[org]
username, issue_id = find_issue_staff_user(issues, users, issue_staff, issue_admin)
data = request_and_response_data(issue_id, username=username)
self._test_check_response(username, issue_id, data, is_allow)
@pytest.mark.parametrize("org", [2])
@pytest.mark.parametrize(
"role, issue_staff, issue_admin, is_allow",
[
("maintainer", True, None, True),
("maintainer", False, None, True),
("supervisor", True, None, True),
("supervisor", False, None, False),
("owner", True, None, True),
("owner", False, None, True),
("worker", False, True, True),
("worker", True, False, True),
("worker", False, False, False),
],
)
def test_member_update_issue(
self,
org,
role,
issue_staff,
issue_admin,
is_allow,
find_issue_staff_user,
find_users,
issues_by_org,
request_and_response_data,
):
users = find_users(role=role, org=org)
issues = issues_by_org[org]
username, issue_id = find_issue_staff_user(issues, users, issue_staff, issue_admin)
data = request_and_response_data(issue_id, username=username)
self._test_check_response(username, issue_id, data, is_allow)
@pytest.mark.usefixtures("restore_db_per_function")
class TestDeleteIssues:
def _test_check_response(self, user, issue_id, expect_success, **kwargs):
with make_api_client(user) as client:
_, response = client.issues_api.destroy(
issue_id,
**kwargs,
_parse_response=False,
_check_status=False,
)
if expect_success:
assert response.status == HTTPStatus.NO_CONTENT
_, response = client.issues_api.retrieve(
issue_id, _parse_response=False, _check_status=False
)
assert response.status == HTTPStatus.NOT_FOUND
else:
assert response.status == HTTPStatus.FORBIDDEN
@pytest.mark.parametrize("org", [""])
@pytest.mark.parametrize(
"privilege, issue_staff, issue_admin, expect_success",
[
("admin", True, None, True),
("admin", False, None, True),
("user", True, None, True),
("user", False, None, False),
("worker", False, True, True),
("worker", True, False, False),
("worker", False, False, False),
],
)
def test_user_delete_issue(
self,
org,
privilege,
issue_staff,
issue_admin,
expect_success,
find_issue_staff_user,
find_users,
issues_by_org,
):
users = find_users(privilege=privilege)
issues = issues_by_org[org]
username, issue_id = find_issue_staff_user(issues, users, issue_staff, issue_admin)
self._test_check_response(username, issue_id, expect_success)
@pytest.mark.parametrize("org", [2])
@pytest.mark.parametrize(
"role, issue_staff, issue_admin, expect_success",
[
("maintainer", True, None, True),
("maintainer", False, None, True),
("supervisor", True, None, True),
("supervisor", False, None, False),
("owner", True, None, True),
("owner", False, None, True),
("worker", False, True, True),
("worker", True, False, False),
("worker", False, False, False),
],
)
def test_org_member_delete_issue(
self,
org,
role,
issue_staff,
issue_admin,
expect_success,
find_issue_staff_user,
find_users,
issues_by_org,
):
users = find_users(role=role, org=org)
issues = issues_by_org[org]
username, issue_id = find_issue_staff_user(issues, users, issue_staff, issue_admin)
self._test_check_response(username, issue_id, expect_success)
class TestIssuesListFilters(CollectionSimpleFilterTestBase):
field_lookups = {
"owner": ["owner", "username"],
"assignee": ["assignee", "username"],
"job_id": ["job"],
"frame_id": ["frame"],
}
@pytest.fixture(autouse=True)
def setup(self, restore_db_per_class, admin_user, issues):
self.user = admin_user
self.samples = issues
def _get_endpoint(self, api_client: ApiClient) -> Endpoint:
return api_client.issues_api.list_endpoint
@pytest.mark.parametrize(
"field",
("owner", "assignee", "job_id", "resolved", "frame_id"),
)
def test_can_use_simple_filter_for_object_list(self, field):
return super()._test_can_use_simple_filter_for_object_list(field)
class TestCommentsListFilters(CollectionSimpleFilterTestBase):
field_lookups = {
"owner": ["owner", "username"],
"issue_id": ["issue"],
}
@pytest.fixture(autouse=True)
def setup(self, restore_db_per_class, admin_user, comments, issues):
self.user = admin_user
self.samples = comments
self.sample_issues = issues
def _get_endpoint(self, api_client: ApiClient) -> Endpoint:
return api_client.comments_api.list_endpoint
def _get_field_samples(self, field: str) -> tuple[Any, list[dict[str, Any]]]:
if field == "job_id":
issue_id, issue_comments = super()._get_field_samples("issue_id")
issue = next(s for s in self.sample_issues if s["id"] == issue_id)
return issue["job"], issue_comments
elif field == "frame_id":
frame_id = self._find_valid_field_value(self.sample_issues, ["frame"])
issues = [s["id"] for s in self.sample_issues if s["frame"] == frame_id]
comments = [
s for s in self.samples if self._get_field(s, self._map_field("issue_id")) in issues
]
return frame_id, comments
else:
return super()._get_field_samples(field)
@pytest.mark.parametrize(
"field",
("owner", "issue_id", "job_id", "frame_id"),
)
def test_can_use_simple_filter_for_object_list(self, field):
return super()._test_can_use_simple_filter_for_object_list(field)
@pytest.mark.usefixtures("restore_db_per_class")
class TestListIssues:
def _test_can_see_issues(self, user, data, **kwargs):
response = get_method(user, "issues", **kwargs)
assert response.status_code == HTTPStatus.OK
assert DeepDiff(data, response.json()["results"]) == {}
def test_admin_can_see_all_issues(self, issues):
self._test_can_see_issues("admin2", issues.raw, page_size="all")
@pytest.mark.parametrize("field_value, query_value", [(1, 1), (None, "")])
def test_can_filter_by_org_id(self, field_value, query_value, issues, jobs):
issues = filter(lambda i: jobs[i["job"]]["organization"] == field_value, issues)
self._test_can_see_issues("admin2", list(issues), page_size="all", org_id=query_value)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+221
View File
@@ -0,0 +1,221 @@
# Copyright (C) 2021-2022 Intel Corporation
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
from http import HTTPStatus
from typing import ClassVar
import pytest
from cvat_sdk.api_client.api_client import ApiClient, Endpoint
from cvat_sdk.api_client.exceptions import ForbiddenException
from deepdiff import DeepDiff
from shared.utils.config import get_method, make_api_client, patch_method
from .utils import CollectionSimpleFilterTestBase
@pytest.mark.usefixtures("restore_db_per_class")
class TestGetMemberships:
def _test_can_see_memberships(self, user, data, **kwargs):
response = get_method(user, "memberships", **kwargs)
assert response.status_code == HTTPStatus.OK
assert DeepDiff(data, response.json()["results"]) == {}
def _test_cannot_see_memberships(self, user, **kwargs):
response = get_method(user, "memberships", **kwargs)
assert response.status_code == HTTPStatus.FORBIDDEN
def test_admin_can_see_all_memberships(self, memberships):
self._test_can_see_memberships("admin2", memberships.raw, page_size="all")
@pytest.mark.parametrize("field_value, query_value", [(1, 1), (None, "")])
def test_can_filter_by_org_id(self, field_value, query_value, memberships):
memberships = filter(lambda m: m["organization"] == field_value, memberships)
self._test_can_see_memberships(
"admin2", list(memberships), page_size="all", org_id=query_value
)
def test_non_admin_can_see_only_self_memberships(self, memberships):
non_admins = ["user1", "dummy1", "worker2"]
for username in non_admins:
data = [obj for obj in memberships if obj["user"]["username"] == username]
self._test_can_see_memberships(username, data)
def test_all_members_can_see_other_members_membership(self, memberships):
data = [obj for obj in memberships if obj["organization"] == 1]
for membership in data:
self._test_can_see_memberships(membership["user"]["username"], data, org_id=1)
def test_non_members_cannot_see_members_membership(self):
non_org1_users = ["user2", "worker3"]
for user in non_org1_users:
self._test_cannot_see_memberships(user, org_id=1)
class TestMembershipsListFilters(CollectionSimpleFilterTestBase):
field_lookups = {
"user": ["user", "username"],
}
@pytest.fixture(autouse=True)
def setup(self, restore_db_per_class, admin_user, memberships):
self.user = admin_user
self.samples = memberships
def _get_endpoint(self, api_client: ApiClient) -> Endpoint:
return api_client.memberships_api.list_endpoint
@pytest.mark.parametrize(
"field",
("role", "user"),
)
def test_can_use_simple_filter_for_object_list(self, field):
return super()._test_can_use_simple_filter_for_object_list(field)
@pytest.mark.usefixtures("restore_db_per_function")
class TestPatchMemberships:
_ORG: ClassVar[int] = 1
ROLES: ClassVar[list[str]] = ["worker", "supervisor", "maintainer", "owner"]
def _test_can_change_membership(self, user, membership_id, new_role):
response = patch_method(
user, f"memberships/{membership_id}", {"role": new_role}, org_id=self._ORG
)
assert response.status_code == HTTPStatus.OK
assert response.json()["role"] == new_role
def _test_cannot_change_membership(self, user, membership_id, new_role):
response = patch_method(
user, f"memberships/{membership_id}", {"role": new_role}, org_id=self._ORG
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.parametrize(
"who, whom, new_role, is_allow",
[
("worker", "worker", "supervisor", False),
("worker", "supervisor", "worker", False),
("worker", "maintainer", "worker", False),
("worker", "owner", "worker", False),
("supervisor", "worker", "supervisor", False),
("supervisor", "supervisor", "worker", False),
("supervisor", "maintainer", "supervisor", False),
("supervisor", "owner", "worker", False),
("maintainer", "maintainer", "worker", False),
("maintainer", "owner", "worker", False),
("maintainer", "supervisor", "worker", True),
("maintainer", "worker", "supervisor", True),
("owner", "maintainer", "worker", True),
("owner", "supervisor", "worker", True),
("owner", "worker", "supervisor", True),
],
)
def test_user_can_change_role_of_member(self, who, whom, new_role, is_allow, find_users):
user = find_users(org=self._ORG, role=who)[0]["username"]
membership_id = find_users(org=self._ORG, role=whom, exclude_username=user)[0][
"membership_id"
]
if is_allow:
self._test_can_change_membership(user, membership_id, new_role)
else:
self._test_cannot_change_membership(user, membership_id, new_role)
@pytest.mark.parametrize(
"who",
ROLES,
)
def test_user_cannot_change_self_role(self, who: str, find_users):
user = find_users(org=self._ORG, role=who)[0]
self._test_cannot_change_membership(
user["username"], user["membership_id"], self.ROLES[abs(self.ROLES.index(who) - 1)]
)
def test_malefactor_cannot_obtain_membership_details_via_empty_partial_update_request(
self, regular_lonely_user, memberships
):
membership = next(iter(memberships))
with make_api_client(regular_lonely_user) as api_client:
with pytest.raises(ForbiddenException):
api_client.memberships_api.partial_update(membership["id"])
def test_user_cannot_update_unknown_field(self, admin_user, memberships):
membership = next(iter(memberships))
response = patch_method(
admin_user, f"memberships/{membership['id']}", {"foo": "bar"}, org_id=self._ORG
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.usefixtures("restore_db_per_function")
class TestDeleteMemberships:
_ORG: ClassVar[int] = 1
def _test_delete_membership(
self,
who: str,
membership_id: int,
is_allow: bool,
) -> None:
expected_status = HTTPStatus.NO_CONTENT if is_allow else HTTPStatus.FORBIDDEN
with make_api_client(who) as api_client:
_, response = api_client.memberships_api.destroy(membership_id, _check_status=False)
assert response.status == expected_status
@pytest.mark.parametrize(
"who, is_allow",
[
("worker", True),
("supervisor", True),
("maintainer", True),
("owner", False),
],
)
def test_member_can_leave_organization(self, who, is_allow, find_users):
user = find_users(role=who, org=self._ORG)[0]
self._test_delete_membership(user["username"], user["membership_id"], is_allow)
@pytest.mark.parametrize(
"who, whom, is_allow",
[
("worker", "worker", False),
("worker", "supervisor", False),
("worker", "maintainer", False),
("worker", "owner", False),
("supervisor", "worker", False),
("supervisor", "supervisor", False),
("supervisor", "maintainer", False),
("supervisor", "owner", False),
("maintainer", "worker", True),
("maintainer", "supervisor", True),
("maintainer", "maintainer", False),
("maintainer", "owner", False),
("owner", "worker", True),
("owner", "supervisor", True),
("owner", "maintainer", True),
],
)
def test_member_can_exclude_another_member(
self,
who: str,
whom: str,
is_allow: bool,
find_users,
):
user = find_users(role=who, org=self._ORG)[0]["username"]
membership_id = find_users(role=whom, org=self._ORG, exclude_username=user)[0][
"membership_id"
]
self._test_delete_membership(user, membership_id, is_allow)
+270
View File
@@ -0,0 +1,270 @@
# Copyright (C) 2021-2022 Intel Corporation
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import json
from copy import deepcopy
from http import HTTPStatus
import pytest
from cvat_sdk.api_client.api_client import ApiClient, Endpoint
from deepdiff import DeepDiff
from shared.utils.config import (
delete_method,
get_method,
make_api_client,
options_method,
patch_method,
)
from .utils import CollectionSimpleFilterTestBase
class TestMetadataOrganizations:
_ORG = 2
@pytest.mark.parametrize(
"privilege, role, is_member",
[
("admin", None, None),
("user", None, False),
("worker", None, False),
(None, "owner", True),
(None, "maintainer", True),
(None, "worker", True),
(None, "supervisor", True),
],
)
def test_can_send_options_request(
self,
privilege: str | None,
role: str | None,
is_member: bool | None,
find_users,
organizations,
):
exclude_org = None if is_member else self._ORG
org = self._ORG if is_member else None
filters = {}
for key, value in {
"privilege": privilege,
"role": role,
"org": org,
"exclude_org": exclude_org,
}.items():
if value is not None:
filters[key] = value
user = find_users(**filters)[0]["username"]
response = options_method(user, f"organizations")
assert response.status_code == HTTPStatus.OK
response = options_method(user, f"organizations/{self._ORG}")
assert response.status_code == HTTPStatus.OK
@pytest.mark.usefixtures("restore_db_per_class")
class TestGetOrganizations:
_ORG = 2
@pytest.mark.parametrize(
"privilege, role, is_member, is_allow",
[
("admin", None, None, True),
("user", None, False, False),
("worker", None, False, False),
(None, "owner", True, True),
(None, "maintainer", True, True),
(None, "worker", True, True),
(None, "supervisor", True, True),
],
)
def test_can_see_specific_organization(
self,
privilege: str | None,
role: str | None,
is_member: bool | None,
is_allow: bool,
find_users,
organizations,
):
exclude_org = None if is_member else self._ORG
org = self._ORG if is_member else None
filters = {}
for key, value in {
"privilege": privilege,
"role": role,
"org": org,
"exclude_org": exclude_org,
}.items():
if value is not None:
filters[key] = value
user = find_users(**filters)[0]["username"]
response = get_method(user, f"organizations/{self._ORG}")
if is_allow:
assert response.status_code == HTTPStatus.OK
assert DeepDiff(organizations[self._ORG], response.json()) == {}
else:
assert response.status_code == HTTPStatus.FORBIDDEN
def test_can_remove_owner_and_fetch_with_sdk(self, admin_user, organizations):
# test for API schema regressions
source_org = next(
org
for org in organizations
if org.get("owner") and org["owner"]["username"] != admin_user
).copy()
with make_api_client(admin_user) as api_client:
api_client.users_api.destroy(source_org["owner"]["id"])
_, response = api_client.organizations_api.retrieve(source_org["id"])
fetched_org = json.loads(response.data)
source_org["owner"] = None
assert DeepDiff(source_org, fetched_org, ignore_order=True) == {}
class TestOrganizationsListFilters(CollectionSimpleFilterTestBase):
field_lookups = {
"owner": ["owner", "username"],
}
@pytest.fixture(autouse=True)
def setup(self, restore_db_per_class, admin_user, organizations):
self.user = admin_user
self.samples = organizations
def _get_endpoint(self, api_client: ApiClient) -> Endpoint:
return api_client.organizations_api.list_endpoint
@pytest.mark.parametrize(
"field",
("name", "owner", "slug"),
)
def test_can_use_simple_filter_for_object_list(self, field):
return super()._test_can_use_simple_filter_for_object_list(field)
@pytest.mark.usefixtures("restore_db_per_function")
class TestPatchOrganizations:
_ORG = 2
@pytest.fixture(scope="class")
def request_data(self):
return {
"slug": "new",
"name": "new",
"description": "new",
"contact": {"email": "new@cvat.org"},
}
@pytest.fixture(scope="class")
def expected_data(self, organizations, request_data):
data = deepcopy(organizations[self._ORG])
data.update(request_data)
return data
@pytest.mark.parametrize(
"privilege, role, is_member, is_allow",
[
("admin", None, None, True),
("user", None, False, False),
("worker", None, False, False),
(None, "owner", True, True),
(None, "maintainer", True, True),
(None, "worker", True, False),
(None, "supervisor", True, False),
],
)
def test_can_update_specific_organization(
self,
privilege: str | None,
role: str | None,
is_member: bool | None,
is_allow: bool,
find_users,
request_data,
expected_data,
):
exclude_org = None if is_member else self._ORG
org = self._ORG if is_member else None
filters = {}
for key, value in {
"privilege": privilege,
"role": role,
"org": org,
"exclude_org": exclude_org,
}.items():
if value is not None:
filters[key] = value
user = find_users(**filters)[0]["username"]
response = patch_method(user, f"organizations/{self._ORG}", request_data)
if is_allow:
assert response.status_code == HTTPStatus.OK
assert (
DeepDiff(expected_data, response.json(), exclude_paths="root['updated_date']") == {}
)
else:
assert response.status_code != HTTPStatus.OK
@pytest.mark.usefixtures("restore_db_per_function")
class TestDeleteOrganizations:
_ORG = 2
@pytest.mark.parametrize(
"privilege, role, is_member, is_allow",
[
("admin", None, None, True),
(None, "owner", True, True),
(None, "maintainer", True, False),
(None, "worker", True, False),
(None, "supervisor", True, False),
("user", None, False, False),
("worker", None, False, False),
],
)
def test_can_delete(
self,
privilege: str | None,
role: str | None,
is_member: bool | None,
is_allow: bool,
find_users,
):
exclude_org = None if is_member else self._ORG
org = self._ORG if is_member else None
filters = {}
for key, value in {
"privilege": privilege,
"role": role,
"org": org,
"exclude_org": exclude_org,
}.items():
if value is not None:
filters[key] = value
user = find_users(**filters)[0]["username"]
response = delete_method(user, f"organizations/{self._ORG}")
if is_allow:
assert response.status_code == HTTPStatus.NO_CONTENT
else:
assert response.status_code != HTTPStatus.OK
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+75
View File
@@ -0,0 +1,75 @@
# Copyright (C) 2022 Intel Corporation
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
from http import HTTPStatus
from time import sleep
from typing import Any
import pytest
from shared.utils.config import get_method, post_method
def _post_task_remote_data(username, task_id, resources):
data = {
"remote_files": resources,
"image_quality": 30,
}
return post_method(username, f"tasks/{task_id}/data", data)
def _wait_until_task_is_created(username: str, rq_id: str) -> dict[str, Any]:
url = f"requests/{rq_id}"
for _ in range(100):
response = get_method(username, url)
request_details = response.json()
if request_details["status"] in ("finished", "failed"):
return request_details
sleep(1)
raise Exception("Cannot create task")
@pytest.mark.usefixtures("restore_db_per_function")
class TestCreateFromRemote:
task_id = 12
def _test_can_create(self, user, task_id, resources):
response = _post_task_remote_data(user, task_id, resources)
assert response.status_code == HTTPStatus.ACCEPTED
response = response.json()
rq_id = response.get("rq_id")
assert rq_id, "The rq_id param was not found in the server response"
response_json = _wait_until_task_is_created(user, rq_id)
assert response_json["status"] == "finished"
def _test_cannot_create(self, user, task_id, resources):
response = _post_task_remote_data(user, task_id, resources)
assert response.status_code == HTTPStatus.ACCEPTED
response = response.json()
rq_id = response.get("rq_id")
assert rq_id, "The rq_id param was not found in the server response"
response_json = _wait_until_task_is_created(user, rq_id)
assert response_json["status"] == "failed"
return response_json
def test_can_report_failed_remote_file_download(self, find_users):
user = find_users(privilege="admin")[0]["username"]
remote_resources = ["http://localhost/favicon.ico"]
response_json = self._test_cannot_create(user, self.task_id, remote_resources)
message = response_json["message"]
assert "rest_framework.exceptions.ValidationError" in message, message
assert f"Failed to download {remote_resources[0]}" in message, message
def test_can_create(self, find_users):
user = find_users(privilege="admin")[0]["username"]
remote_resources = ["https://docs.cvat.ai/favicons/favicon-32x32.png"]
self._test_can_create(user, self.task_id, remote_resources)
+688
View File
@@ -0,0 +1,688 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import io
import json
from http import HTTPStatus
from urllib.parse import parse_qsl, urlparse
import pytest
from cvat_sdk.api_client import ApiClient, models
from cvat_sdk.api_client.api_client import Endpoint
from cvat_sdk.core.helpers import get_paginated_collection
from shared.fixtures.data import Container
from shared.fixtures.init import docker_exec_redis_inmem, kube_exec_redis_inmem
from shared.utils.config import make_api_client
from shared.utils.helpers import generate_image_files
from .utils import (
CollectionSimpleFilterTestBase,
create_task,
export_dataset,
export_job_dataset,
export_project_backup,
export_project_dataset,
export_task_backup,
export_task_dataset,
import_job_annotations,
import_project_backup,
import_project_dataset,
import_task_annotations,
import_task_backup,
wait_background_request,
)
@pytest.mark.timeout(60)
@pytest.mark.usefixtures("restore_db_per_class")
@pytest.mark.usefixtures("restore_redis_inmem_per_class")
@pytest.mark.usefixtures("restore_redis_ondisk_per_class")
class TestRequestsListFilters(CollectionSimpleFilterTestBase):
# Org used for the org/org_id filter cases. Only the id is hardcoded;
# the slug is derived from the `organizations` fixture at setup time.
_ORG_ID = 2
field_lookups = {
"target": ["operation", "target"],
"subresource": ["operation", "type", lambda x: x.split(":")[1]],
"action": ["operation", "type", lambda x: x.split(":")[0]],
"project_id": ["operation", "project_id"],
"task_id": ["operation", "task_id"],
"job_id": ["operation", "job_id"],
"format": ["operation", "format"],
"org_id": ["operation", "org_id"],
}
def _get_endpoint(self, api_client: ApiClient) -> Endpoint:
return api_client.requests_api.list_endpoint
@pytest.fixture(scope="class")
@classmethod
def setup_user(cls, restore_db_per_class, find_users):
cls.user = find_users(privilege="user")[0]["username"]
@pytest.fixture(scope="class")
@classmethod
def setup_org_users(cls, restore_db_per_class, find_users, organizations, memberships):
cls.org_slug = next(o["slug"] for o in organizations if o["id"] == cls._ORG_ID)
# Pick an owner/maintainer so they can create projects/tasks in the org.
cls.org_user = next(
find_users(id=m["user"]["id"])[0]["username"]
for m in memberships
if m["organization"] == cls._ORG_ID
and m["user"]
and m["role"] in ("owner", "maintainer")
)
@pytest.fixture(scope="class")
@classmethod
def fxt_resources_ids(
cls, setup_user, restore_redis_inmem_per_class, restore_redis_ondisk_per_class
):
with make_api_client(cls.user) as api_client:
project_ids = [
api_client.projects_api.create(
{"name": f"Test project {idx + 1}", "labels": [{"name": "car"}]}
)[0].id
for idx in range(3)
]
task_ids = [
create_task(
cls.user,
spec={"name": f"Test task {idx + 1}"},
data={
"image_quality": 75,
"client_files": generate_image_files(2),
"segment_size": 1,
},
)[0]
for idx in range(3)
]
job_ids = []
for task_id in task_ids:
jobs, _ = api_client.jobs_api.list(task_id=task_id)
job_ids.extend([j.id for j in jobs.results])
return project_ids, task_ids, job_ids
@pytest.fixture(scope="class")
@classmethod
def fxt_make_requests(
cls,
setup_user,
fxt_make_export_project_requests,
fxt_make_export_task_requests,
fxt_make_export_job_requests,
fxt_download_file,
):
def _make_requests(project_ids: list[int], task_ids: list[int], job_ids: list[int]):
# make requests to export projects|tasks|jobs annotations|datasets|backups
fxt_make_export_project_requests(project_ids[1:])
fxt_make_export_task_requests(task_ids[1:])
fxt_make_export_job_requests(job_ids[1:])
# make requests to download files and then import them
for resource_type, first_resource in zip(
("project", "task", "job"), (project_ids[0], task_ids[0], job_ids[0])
):
for subresource in ("dataset", "annotations", "backup"):
if resource_type == "job" and subresource == "backup":
continue
data = fxt_download_file(resource_type, first_resource, subresource)
tmp_file = io.BytesIO(data)
tmp_file.name = f"{resource_type}_{subresource}.zip"
if resource_type == "task" and subresource == "backup":
import_task_backup(
cls.user,
file_content=tmp_file,
)
empty_file = io.BytesIO(b"empty_file")
empty_file.name = "empty.zip"
# import corrupted backup
import_task_backup(
cls.user,
file_content=empty_file,
)
return _make_requests
@pytest.fixture(scope="class")
@classmethod
def fxt_download_file(cls, setup_user):
def download_file(resource: str, rid: int, subresource: str):
func = {
("project", "dataset"): lambda *args, **kwargs: export_project_dataset(
*args, **kwargs, save_images=True
),
("project", "annotations"): lambda *args, **kwargs: export_project_dataset(
*args, **kwargs, save_images=False
),
("project", "backup"): export_project_backup,
("task", "dataset"): lambda *args, **kwargs: export_task_dataset(
*args, **kwargs, save_images=True
),
("task", "annotations"): lambda *args, **kwargs: export_task_dataset(
*args, **kwargs, save_images=False
),
("task", "backup"): export_task_backup,
("job", "dataset"): lambda *args, **kwargs: export_job_dataset(
*args, **kwargs, save_images=True
),
("job", "annotations"): lambda *args, **kwargs: export_job_dataset(
*args, **kwargs, save_images=False
),
}[(resource, subresource)]
data = func(cls.user, id=rid, download_result=True)
assert data, f"Failed to download {resource} {subresource} locally"
return data
return download_file
@pytest.fixture(scope="class")
@classmethod
def fxt_make_export_project_requests(cls, setup_user):
def make_requests(project_ids: list[int]):
for project_id in project_ids:
export_project_backup(cls.user, id=project_id, download_result=False)
export_project_dataset(
cls.user, save_images=True, id=project_id, download_result=False
)
export_project_dataset(
cls.user,
save_images=False,
id=project_id,
download_result=False,
)
return make_requests
@pytest.fixture(scope="class")
@classmethod
def fxt_make_export_task_requests(cls, setup_user):
def make_requests(task_ids: list[int]):
for task_id in task_ids:
export_task_backup(cls.user, id=task_id, download_result=False)
export_task_dataset(cls.user, save_images=True, id=task_id, download_result=False)
export_task_dataset(cls.user, save_images=False, id=task_id, download_result=False)
return make_requests
@pytest.fixture(scope="class")
@classmethod
def fxt_make_export_job_requests(cls, setup_user):
def make_requests(job_ids: list[int]):
for job_id in job_ids:
export_job_dataset(
cls.user,
save_images=True,
id=job_id,
format="COCO 1.0",
download_result=False,
)
export_job_dataset(
cls.user,
save_images=False,
id=job_id,
format="YOLO 1.1",
download_result=False,
)
return make_requests
@pytest.fixture(scope="class")
@classmethod
def fxt_org_resources(cls, setup_org_users):
with make_api_client(cls.org_user) as api_client:
api_client.set_default_header("X-Organization", cls.org_slug)
cls.org_project_id = api_client.projects_api.create(
{"name": "Org test project", "labels": [{"name": "car"}]},
)[0].id
cls.org_task_id = create_task(
cls.org_user,
spec={"name": "Org test task"},
data={
"image_quality": 75,
"client_files": generate_image_files(2),
"segment_size": 1,
},
org_id=cls._ORG_ID,
)[0]
export_dataset(
api_client.projects_api,
save_images=True,
id=cls.org_project_id,
download_result=False,
)
export_dataset(
api_client.tasks_api,
save_images=True,
id=cls.org_task_id,
download_result=False,
)
@pytest.fixture(scope="class", autouse=True)
@classmethod
def setup(
cls,
setup_user,
setup_org_users,
fxt_resources_ids,
fxt_make_requests,
fxt_org_resources,
):
cls.project_ids, cls.task_ids, cls.job_ids = fxt_resources_ids
fxt_make_requests(cls.project_ids, cls.task_ids, cls.job_ids)
def _get_field_samples(self, field):
if field == "org":
gt_objects = [
s
for s in self.samples
if self._get_field(s, ["operation", "org_id"]) == self._ORG_ID
]
return self.org_slug, gt_objects
return super()._get_field_samples(field)
@pytest.mark.parametrize(
"simple_filter, values",
[
("subresource", ["annotations", "dataset", "backup"]),
("action", ["create", "export", "import"]),
("status", ["finished", "failed"]),
("project_id", []),
("task_id", []),
("job_id", []),
("format", ["CVAT for images 1.1", "COCO 1.0", "YOLO 1.1"]),
("target", ["project", "task", "job"]),
("org_id", []),
("org", []),
],
)
def test_can_use_simple_filter_for_object_list(self, simple_filter: str, values: list):
if simple_filter == "project_id":
values = self.project_ids[-1:]
elif simple_filter == "task_id":
values = self.task_ids[-1:]
elif simple_filter == "job_id":
values = self.job_ids[-1:]
elif simple_filter == "org_id":
values = [self._ORG_ID]
with make_api_client(self.user) as api_client:
self.samples = get_paginated_collection(
self._get_endpoint(api_client), return_json=True
)
return super()._test_can_use_simple_filter_for_object_list(simple_filter, values)
@pytest.mark.usefixtures("restore_db_per_function")
@pytest.mark.usefixtures("restore_redis_inmem_per_function")
def test_list_requests_when_there_is_job_with_non_regular_or_corrupted_meta(
jobs: Container, admin_user: str, request: pytest.FixtureRequest
):
job = next(j for j in jobs if j["media_type"] != "audio")
export_job_dataset(admin_user, save_images=True, id=job["id"], download_result=False)
export_job_dataset(admin_user, save_images=False, id=job["id"], download_result=False)
with make_api_client(admin_user) as api_client:
background_requests, response = api_client.requests_api.list(_check_status=False)
assert response.status == HTTPStatus.OK
assert 2 == background_requests.count
corrupted_job, normal_job = background_requests.results
corrupted_job_key = f"rq:job:{corrupted_job['id']}"
remove_meta_command = f'redis-cli -e HDEL "{corrupted_job_key}" meta'
if request.config.getoption("--platform") == "local":
stdout = docker_exec_redis_inmem(["sh", "-c", remove_meta_command])
else:
stdout = kube_exec_redis_inmem(
[
"sh",
"-c",
'export REDISCLI_AUTH="${REDIS_PASSWORD}" && ' + remove_meta_command,
]
)
assert bool(int(stdout.strip()))
_, response = api_client.requests_api.list(_check_status=False, _parse_response=False)
assert response.status == HTTPStatus.OK
background_requests = json.loads(response.data)
assert 1 == background_requests["count"]
assert normal_job.id == background_requests["results"][0]["id"]
@pytest.mark.usefixtures("restore_db_per_class")
@pytest.mark.usefixtures("restore_redis_inmem_per_function")
class TestGetRequests:
def _test_get_request_200(
self, api_client: ApiClient, rq_id: str, validate_rq_id: bool = True, **kwargs
) -> models.Request:
background_request, response = api_client.requests_api.retrieve(rq_id, **kwargs)
assert response.status == HTTPStatus.OK
if validate_rq_id:
assert background_request.id == rq_id
return background_request
def _test_get_request_403(self, api_client: ApiClient, rq_id: str):
_, response = api_client.requests_api.retrieve(
rq_id, _parse_response=False, _check_status=False
)
assert response.status == HTTPStatus.FORBIDDEN
@pytest.mark.parametrize("format_name", ("CVAT for images 1.1",))
@pytest.mark.parametrize("save_images", (True, False))
def test_owner_can_retrieve_request(self, format_name: str, save_images: bool, projects):
project = next(
p
for p in projects
if p["owner"] and (p["target_storage"] or {}).get("location") == "local"
)
owner = project["owner"]
subresource = "dataset" if save_images else "annotations"
request_id = export_project_dataset(
owner["username"],
save_images=save_images,
id=project["id"],
download_result=False,
format=format_name,
)
with make_api_client(owner["username"]) as owner_client:
bg_request = self._test_get_request_200(owner_client, request_id)
assert (
bg_request.created_date
< bg_request.started_date
< bg_request.finished_date
< bg_request.expiry_date
)
assert bg_request.operation.format == format_name
assert bg_request.operation.project_id == project["id"]
assert bg_request.operation.target == "project"
assert bg_request.operation.task_id is None
assert bg_request.operation.job_id is None
assert bg_request.operation.type == f"export:{subresource}"
assert bg_request.owner.id == owner["id"]
assert bg_request.owner.username == owner["username"]
parsed_url = urlparse(bg_request.result_url)
assert all([parsed_url.scheme, parsed_url.netloc, parsed_url.path, parsed_url.query])
def test_non_owner_cannot_retrieve_request(self, find_users, projects):
project = next(
p
for p in projects
if p["owner"] and (p["target_storage"] or {}).get("location") == "local"
)
owner = project["owner"]
malefactor = find_users(exclude_username=owner["username"])[0]
request_id = export_project_dataset(
owner["username"],
save_images=True,
id=project["id"],
download_result=False,
)
with make_api_client(malefactor["username"]) as malefactor_client:
self._test_get_request_403(malefactor_client, request_id)
def _test_get_request_using_legacy_id(
self,
legacy_request_id: str,
username: str,
*,
action: str,
target_type: str,
subresource: str | None = None,
):
with make_api_client(username) as api_client:
bg_requests, _ = api_client.requests_api.list(
target=target_type,
action=action,
**({"subresource": subresource} if subresource else {}),
)
assert len(bg_requests.results) == 1
request_id = bg_requests.results[0].id
bg_request = self._test_get_request_200(
api_client, legacy_request_id, validate_rq_id=False
)
assert bg_request.id == request_id
@pytest.mark.parametrize("target_type", ("project", "task", "job"))
@pytest.mark.parametrize("save_images", (True, False))
@pytest.mark.parametrize("export_format", ("CVAT for images 1.1",))
@pytest.mark.parametrize("import_format", ("CVAT 1.1",))
def test_can_retrieve_dataset_import_export_requests_using_legacy_ids(
self,
target_type: str,
save_images: bool,
export_format: str,
import_format: str,
projects,
tasks,
jobs,
):
def build_legacy_id_for_export_request(
*,
target_type: str,
target_id: int,
subresource: str,
format_name: str,
user_id: int,
):
return f"export:{target_type}-{target_id}-{subresource}-in-{format_name.replace(' ', '_').replace('.', '@')}-format-by-{user_id}"
def build_legacy_id_for_import_request(
*,
target_type: str,
target_id: int,
subresource: str,
):
return f"import:{target_type}-{target_id}-{subresource}"
if target_type == "project":
export_func, import_func = export_project_dataset, import_project_dataset
target = next(p for p in projects if p["dimension"] != "1d")
owner = target["owner"]
elif target_type == "task":
export_func, import_func = export_task_dataset, import_task_annotations
target = next(t for t in tasks if t["media_type"] != "audio")
owner = target["owner"]
else:
assert target_type == "job"
export_func, import_func = export_job_dataset, import_job_annotations
target = next(j for j in jobs if j["media_type"] != "audio")
owner = tasks[target["task_id"]]["owner"]
target_id = target["id"]
subresource = "dataset" if save_images else "annotations"
file_content = io.BytesIO(
export_func(
owner["username"],
save_images=save_images,
format=export_format,
id=target_id,
)
)
file_content.name = "file.zip"
legacy_request_id = build_legacy_id_for_export_request(
target_type=target_type,
target_id=target["id"],
subresource=subresource,
format_name=export_format,
user_id=owner["id"],
)
self._test_get_request_using_legacy_id(
legacy_request_id,
owner["username"],
action="export",
target_type=target_type,
subresource=subresource,
)
# check import requests
if not save_images and target_type == "project" or save_images and target_type != "project":
# skip:
# importing annotations into a project
# importing datasets into a task or job
return
import_func(
owner["username"],
file_content=file_content,
id=target_id,
format=import_format,
)
legacy_request_id = build_legacy_id_for_import_request(
target_type=target_type, target_id=target_id, subresource=subresource
)
self._test_get_request_using_legacy_id(
legacy_request_id,
owner["username"],
action="import",
target_type=target_type,
subresource=subresource,
)
@pytest.mark.parametrize("target_type", ("project", "task"))
def test_can_retrieve_backup_import_export_requests_using_legacy_ids(
self,
target_type: str,
projects,
tasks,
):
def build_legacy_id_for_export_request(
*,
target_type: str,
target_id: int,
user_id: int,
):
return f"export:{target_type}-{target_id}-backup-by-{user_id}"
def build_legacy_id_for_import_request(
*,
target_type: str,
uuid_: str,
):
return f"import:{target_type}-{uuid_}-backup"
if target_type == "project":
export_func, import_func = export_project_backup, import_project_backup
target = next(iter(projects))
else:
assert target_type == "task"
export_func, import_func = export_task_backup, import_task_backup
target = next(iter(tasks))
owner = target["owner"]
# check export requests
backup_file = io.BytesIO(
export_func(
owner["username"],
id=target["id"],
)
)
backup_file.name = "file.zip"
legacy_request_id = build_legacy_id_for_export_request(
target_type=target_type, target_id=target["id"], user_id=owner["id"]
)
self._test_get_request_using_legacy_id(
legacy_request_id,
owner["username"],
action="export",
target_type=target_type,
subresource="backup",
)
# check import requests
result_id = import_func(
owner["username"],
file_content=backup_file,
).id
legacy_request_id = build_legacy_id_for_import_request(
target_type=target_type, uuid_=dict(parse_qsl(result_id))["id"]
)
self._test_get_request_using_legacy_id(
legacy_request_id,
owner["username"],
action="import",
target_type=target_type,
subresource="backup",
)
def test_can_retrieve_task_creation_requests_using_legacy_ids(self, admin_user: str):
task_id = create_task(
admin_user,
spec={"name": "Test task"},
data={
"image_quality": 75,
"client_files": generate_image_files(2),
"segment_size": 1,
},
)[0]
legacy_request_id = f"create:task-{task_id}"
self._test_get_request_using_legacy_id(
legacy_request_id, admin_user, action="create", target_type="task"
)
def test_can_retrieve_quality_calculation_requests_using_legacy_ids(self, jobs, tasks):
gt_job = next(
j
for j in jobs
if (
j["type"] == "ground_truth"
and j["stage"] == "acceptance"
and j["state"] == "completed"
)
)
task_id = gt_job["task_id"]
owner = tasks[task_id]["owner"]
legacy_request_id = f"quality-check-task-{task_id}-user-{owner['id']}"
with make_api_client(owner["username"]) as api_client:
# initiate quality report calculation
_, response = api_client.quality_api.create_report(
quality_report_create_request=models.QualityReportCreateRequest(task_id=task_id),
_parse_response=False,
)
assert response.status == HTTPStatus.ACCEPTED
request_id = json.loads(response.data)["rq_id"]
# get background request details using common request API
bg_request = self._test_get_request_200(
api_client, legacy_request_id, validate_rq_id=False
)
assert bg_request.id == request_id
# get quality report by legacy request ID using the deprecated API endpoint
wait_background_request(api_client, request_id)
api_client.quality_api.create_report(
quality_report_create_request=models.QualityReportCreateRequest(task_id=task_id),
rq_id=request_id,
)
@@ -0,0 +1,662 @@
# Copyright (C) 2021-2022 Intel Corporation
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
from collections.abc import Callable
from http import HTTPStatus
from pathlib import Path
from typing import Any
from uuid import uuid4
import pytest
from cvat_sdk import exceptions
from cvat_sdk.core import Client as SdkClient
from cvat_sdk.core.progress import NullProgressReporter
from cvat_sdk.core.uploading import Uploader
from pytest_cases import fixture, fixture_ref, parametrize
from shared.fixtures.data import Container
from shared.utils.config import get_method, make_sdk_client, post_method
from shared.utils.resource_import_export import (
_CloudStorageResourceTest,
_make_export_resource_params,
_make_import_resource_params,
)
from shared.utils.s3 import make_client as make_s3_client
from .utils import create_task
class _S3ResourceTest(_CloudStorageResourceTest):
@staticmethod
def _make_client():
return make_s3_client()
@pytest.mark.with_external_services
@pytest.mark.usefixtures("restore_db_per_class")
class TestExportResourceToS3(_S3ResourceTest):
@pytest.mark.usefixtures("restore_redis_inmem_per_function")
@pytest.mark.parametrize("cloud_storage_id", [3])
@pytest.mark.parametrize(
"obj_id, obj, resource",
[
(2, "projects", "annotations"),
(2, "projects", "dataset"),
(2, "projects", "backup"),
(11, "tasks", "annotations"),
(11, "tasks", "dataset"),
(11, "tasks", "backup"),
(16, "jobs", "annotations"),
(16, "jobs", "dataset"),
],
)
def test_save_resource_to_cloud_storage_with_specific_location(
self, cloud_storage_id, obj_id, obj, resource, cloud_storages
):
cloud_storage = cloud_storages[cloud_storage_id]
kwargs = _make_export_resource_params(
resource, is_default=False, obj=obj, cloud_storage_id=cloud_storage_id
)
self._export_resource(cloud_storage, obj_id, obj, resource, **kwargs)
@pytest.mark.usefixtures("restore_redis_inmem_per_function")
@pytest.mark.parametrize("user_type", ["admin", "assigned_supervisor_org_member"])
@pytest.mark.parametrize(
"obj_id, obj, resource",
[
(2, "projects", "annotations"),
(2, "projects", "dataset"),
(2, "projects", "backup"),
(11, "tasks", "annotations"),
(11, "tasks", "dataset"),
(11, "tasks", "backup"),
(16, "jobs", "annotations"),
(16, "jobs", "dataset"),
],
)
def test_save_resource_to_cloud_storage_with_default_location(
self,
obj_id,
obj,
resource,
user_type,
projects,
tasks,
jobs,
cloud_storages,
users,
is_project_staff,
is_task_staff,
is_job_staff,
is_org_member,
):
objects = {
"projects": projects,
"tasks": tasks,
"jobs": jobs,
}
if obj in ("projects", "tasks"):
cloud_storage_id = objects[obj][obj_id]["target_storage"]["cloud_storage_id"]
else:
task_id = jobs[obj_id]["task_id"]
cloud_storage_id = tasks[task_id]["target_storage"]["cloud_storage_id"]
cloud_storage = cloud_storages[cloud_storage_id]
if user_type == "admin":
user = self.user
elif user_type == "assigned_supervisor_org_member":
is_staff = {"projects": is_project_staff, "tasks": is_task_staff, "jobs": is_job_staff}[
obj
]
user = next(
u
for u in users
if is_staff(u["id"], obj_id)
if is_org_member(u["id"], cloud_storage["organization"], role="supervisor")
)["username"]
else:
assert False
kwargs = _make_export_resource_params(resource, obj=obj)
kwargs["user"] = user
self._export_resource(cloud_storage, obj_id, obj, resource, **kwargs)
@pytest.mark.parametrize("storage_id", [3])
@pytest.mark.parametrize(
"obj, resource",
[
("projects", "annotations"),
("projects", "dataset"),
("projects", "backup"),
("tasks", "annotations"),
("tasks", "dataset"),
("tasks", "backup"),
("jobs", "annotations"),
("jobs", "dataset"),
],
)
def test_user_cannot_export_to_cloud_storage_with_specific_location_without_access(
self, storage_id, regular_lonely_user, obj, resource
):
user = regular_lonely_user
project_spec = {"name": "Test project"}
project = post_method(user, "projects", project_spec).json()
project_id = project["id"]
task_spec = {
"name": f"Task with files from foreign cloud storage {storage_id}",
}
data_spec = {
"image_quality": 75,
"use_cache": True,
"server_files": ["images/image_1.jpg"],
"project_id": project_id,
}
task_id, _ = create_task(user, task_spec, data_spec)
jobs = get_method(user, "jobs", task_id=task_id).json()["results"]
job_id = jobs[0]["id"]
if obj == "projects":
obj_id = project_id
elif obj == "tasks":
obj_id = task_id
elif obj == "jobs":
obj_id = job_id
else:
assert False
kwargs = _make_export_resource_params(
resource, is_default=False, obj=obj, cloud_storage_id=storage_id
)
self._export_resource_to_cloud_storage(
obj_id, obj, resource, user=user, _expect_status=HTTPStatus.FORBIDDEN, **kwargs
)
@pytest.mark.with_external_services
@pytest.mark.usefixtures("restore_db_per_function")
@pytest.mark.usefixtures("restore_cvat_data_per_function")
class TestImportResourceFromS3(_S3ResourceTest):
@pytest.mark.usefixtures("restore_redis_inmem_per_function")
@pytest.mark.parametrize("cloud_storage_id", [3])
@pytest.mark.parametrize(
"obj_id, obj, resource",
[
(2, "projects", "dataset"),
(2, "projects", "backup"),
(11, "tasks", "annotations"),
(11, "tasks", "backup"),
(16, "jobs", "annotations"),
],
)
def test_import_resource_from_cloud_storage_with_specific_location(
self, cloud_storage_id, obj_id, obj, resource, cloud_storages
):
cloud_storage = cloud_storages[cloud_storage_id]
kwargs = _make_import_resource_params(
resource, is_default=False, obj=obj, cloud_storage_id=cloud_storage_id
)
export_kwargs = _make_export_resource_params(
resource, is_default=False, obj=obj, cloud_storage_id=cloud_storage_id
)
self._export_resource(cloud_storage, obj_id, obj, resource, **export_kwargs)
self._import_resource(
cloud_storage,
resource,
*(
[
obj_id,
]
if resource != "backup"
else []
),
obj,
**kwargs,
)
@pytest.mark.usefixtures("restore_redis_inmem_per_function")
@pytest.mark.parametrize(
"user_type",
["admin", "assigned_supervisor_org_member"],
)
@pytest.mark.parametrize(
"obj_id, obj, resource",
[
(2, "projects", "dataset"),
(11, "tasks", "annotations"),
(16, "jobs", "annotations"),
],
)
def test_import_resource_from_cloud_storage_with_default_location(
self,
obj_id,
obj,
resource,
user_type,
projects,
tasks,
jobs,
cloud_storages,
users,
is_project_staff,
is_task_staff,
is_job_staff,
is_org_member,
):
objects = {
"projects": projects,
"tasks": tasks,
"jobs": jobs,
}
if obj in ("projects", "tasks"):
cloud_storage_id = objects[obj][obj_id]["source_storage"]["cloud_storage_id"]
else:
task_id = jobs[obj_id]["task_id"]
cloud_storage_id = tasks[task_id]["source_storage"]["cloud_storage_id"]
cloud_storage = cloud_storages[cloud_storage_id]
if user_type == "admin":
user = self.user
elif user_type == "assigned_supervisor_org_member":
is_staff = {"projects": is_project_staff, "tasks": is_task_staff, "jobs": is_job_staff}[
obj
]
user = next(
u
for u in users
if is_staff(u["id"], obj_id)
if is_org_member(u["id"], cloud_storage["organization"], role="supervisor")
)["username"]
else:
assert False
export_kwargs = _make_export_resource_params(resource, obj=obj)
import_kwargs = _make_import_resource_params(resource, obj=obj)
export_kwargs["user"] = user
import_kwargs["user"] = user
self._export_resource(cloud_storage, obj_id, obj, resource, **export_kwargs)
self._import_resource(cloud_storage, resource, obj_id, obj, **import_kwargs)
@pytest.mark.parametrize("storage_id", [3])
@pytest.mark.parametrize(
"obj, resource",
[
("projects", "dataset"),
("tasks", "annotations"),
("jobs", "annotations"),
("tasks", "backup"),
("projects", "backup"),
],
)
def test_user_cannot_import_from_cloud_storage_with_specific_location_without_access(
self, storage_id, regular_lonely_user, obj, resource, cloud_storages
):
user = regular_lonely_user
project_spec = {"name": "Test project"}
project = post_method(user, "projects", project_spec).json()
project_id = project["id"]
task_spec = {
"name": f"Task with files from foreign cloud storage {storage_id}",
}
data_spec = {
"image_quality": 75,
"use_cache": True,
"server_files": ["images/image_1.jpg"],
"project_id": project_id,
}
task_id, _ = create_task(user, task_spec, data_spec)
jobs = get_method(user, "jobs", task_id=task_id).json()["results"]
job_id = jobs[0]["id"]
if obj == "projects":
obj_id = project_id
elif obj == "tasks":
obj_id = task_id
elif obj == "jobs":
obj_id = job_id
else:
assert False
cloud_storage = cloud_storages[storage_id]
kwargs = _make_import_resource_params(
resource, is_default=False, obj=obj, cloud_storage_id=storage_id
)
if resource == "annotations":
kwargs["_check_uploaded"] = False
self._import_resource(
cloud_storage,
resource,
*(
[
obj_id,
]
if resource != "backup"
else []
),
obj,
user=user,
_expect_status=HTTPStatus.FORBIDDEN,
**kwargs,
)
@pytest.mark.usefixtures("restore_db_per_function")
@pytest.mark.usefixtures("restore_redis_inmem_per_function")
@pytest.mark.usefixtures("restore_redis_ondisk_per_function")
class TestUploads:
@fixture()
def project(self, projects: Container):
return next(p for p in projects)
@fixture()
def task(self, tasks: Container):
return next(t for t in tasks)
@fixture()
def job(self, jobs: Container):
return next(j for j in jobs if j["type"] == "annotation")
@fixture(scope="class")
def restore_task_api_path(self, admin_user: str):
with make_sdk_client(admin_user) as client:
return client.api_client.tasks_api.create_backup_endpoint.path
@fixture(scope="class")
def restore_project_api_path(self, admin_user: str):
with make_sdk_client(admin_user) as client:
return client.api_client.projects_api.create_backup_endpoint.path
@fixture(scope="class")
def upload_task_annotations_api_path(self, admin_user: str):
with make_sdk_client(admin_user) as client:
return client.api_client.tasks_api.create_annotations_endpoint.path
@fixture(scope="class")
def upload_project_dataset_api_path(self, admin_user: str):
with make_sdk_client(admin_user) as client:
return client.api_client.projects_api.create_dataset_endpoint.path
@fixture(scope="class")
def upload_job_annotations_api_path(self, admin_user: str):
with make_sdk_client(admin_user) as client:
return client.api_client.jobs_api.create_annotations_endpoint.path
def _find_malefactor_with_its_resource(
self,
resources: Container,
users: Container,
*,
get_owner_func: Callable = lambda r: r["owner"],
user_to_skip: int | None = None,
) -> tuple[dict, int]:
for resource in resources:
malefactor = get_owner_func(resource)
if not users[malefactor["id"]]["is_superuser"] and malefactor["id"] != user_to_skip:
return malefactor, resource["id"]
assert False
@fixture()
def downloaded_file_path(self, tmp_path: Path):
return tmp_path / f"{uuid4()}.zip"
@fixture()
def project_backup_with_owner_and_malefactor(
self,
project: dict,
projects: Container,
downloaded_file_path: Path,
restore_project_api_path: str,
users: Container,
):
project_id, project_owner = project["id"], project["owner"]
malefactor, _ = self._find_malefactor_with_its_resource(
projects, users, user_to_skip=project_owner["id"]
)
with make_sdk_client(project_owner["username"]) as client:
client.projects.retrieve(project_id).download_backup(downloaded_file_path)
return (
None,
downloaded_file_path,
project_owner,
malefactor,
None,
restore_project_api_path,
None,
)
@fixture()
def project_dataset_with_owner_and_malefactor(
self,
project: dict,
projects: Container,
downloaded_file_path: Path,
upload_project_dataset_api_path: str,
users: Container,
):
project_id, project_owner = project["id"], project["owner"]
malefactor, malefactor_project_id = self._find_malefactor_with_its_resource(
projects, users, user_to_skip=project_owner["id"]
)
with make_sdk_client(project_owner["username"]) as client:
client.projects.retrieve(project_id).export_dataset(
"COCO 1.0", downloaded_file_path, include_images=True
)
return (
project_id,
downloaded_file_path,
project_owner,
malefactor,
malefactor_project_id,
upload_project_dataset_api_path,
{"format": "COCO 1.0"},
)
@fixture()
def task_backup_with_owner_and_malefactor(
self,
task: dict,
tasks: Container,
downloaded_file_path: Path,
restore_task_api_path: str,
users: Container,
):
task_id = task["id"]
task_owner = task["owner"]
malefactor, _ = self._find_malefactor_with_its_resource(
tasks, users, user_to_skip=task_owner["id"]
)
with make_sdk_client(task_owner["username"]) as client:
client.tasks.retrieve(task_id).download_backup(downloaded_file_path)
return (
None,
downloaded_file_path,
task_owner,
malefactor,
None,
restore_task_api_path,
None,
)
@fixture()
def task_annotations_with_owner_and_malefactor(
self,
task: dict,
tasks: Container,
downloaded_file_path: Path,
upload_task_annotations_api_path: str,
users: Container,
):
task_id = task["id"]
task_owner = task["owner"]
malefactor, malefactor_task_id = self._find_malefactor_with_its_resource(
tasks, users, user_to_skip=task_owner["id"]
)
with make_sdk_client(task_owner["username"]) as client:
client.tasks.retrieve(task_id).export_dataset(
"COCO 1.0", downloaded_file_path, include_images=False
)
return (
task_id,
downloaded_file_path,
task_owner,
malefactor,
malefactor_task_id,
upload_task_annotations_api_path,
{"format": "COCO 1.0"},
)
@fixture()
def job_annotations_with_owner_and_malefactor(
self,
job: dict,
jobs: Container,
tasks: Container,
downloaded_file_path: Path,
upload_job_annotations_api_path: str,
users: Container,
):
job_id = job["id"]
job_owner = tasks[job["task_id"]]["owner"]
malefactor, malefactor_job_id = self._find_malefactor_with_its_resource(
jobs,
users,
user_to_skip=job_owner["id"],
get_owner_func=lambda x: tasks[x["task_id"]]["owner"],
)
with make_sdk_client(job_owner["username"]) as client:
client.jobs.retrieve(job_id).export_dataset(
"COCO 1.0", downloaded_file_path, include_images=False
)
return (
job_id,
downloaded_file_path,
job_owner,
malefactor,
malefactor_job_id,
upload_job_annotations_api_path,
{"format": "COCO 1.0"},
)
def _test_can_finish_upload(
self,
client: SdkClient,
url: str,
*,
query_params: dict[str, Any],
):
Uploader(client)._tus_finish_upload(url, query_params=query_params)
def _test_cannot_finish_upload(
self,
client: SdkClient,
url: str,
*,
query_params: dict[str, Any],
):
uploader = Uploader(client)
with pytest.raises(exceptions.ApiException) as capture:
uploader._tus_finish_upload(url, query_params=query_params)
assert capture.value.status == HTTPStatus.BAD_REQUEST
assert b"No such file were uploaded" in capture.value.body
@parametrize(
"resource, endpoint_path",
[
(None, fixture_ref(restore_task_api_path)),
(None, fixture_ref(restore_project_api_path)),
(fixture_ref(task), fixture_ref(upload_task_annotations_api_path)),
(fixture_ref(project), fixture_ref(upload_project_dataset_api_path)),
(fixture_ref(job), fixture_ref(upload_job_annotations_api_path)),
],
)
def test_user_cannot_restore_resource_from_non_existent_uploads(
self, resource: dict, endpoint_path: str, admin_user: str
):
with make_sdk_client(admin_user) as client:
url = client.api_map.make_endpoint_url(
endpoint_path, kwsub=({"id": resource["id"]} if resource else None)
)
self._test_cannot_finish_upload(
client, url, query_params={"filename": "non-existent-file.zip"}
)
@parametrize(
"src_resource_id, archive_path, owner, malefactor, dst_resource_id, endpoint_path, query_params",
[
(fixture_ref(task_backup_with_owner_and_malefactor)),
(fixture_ref(project_backup_with_owner_and_malefactor)),
(fixture_ref(job_annotations_with_owner_and_malefactor)),
(fixture_ref(task_annotations_with_owner_and_malefactor)),
(fixture_ref(project_dataset_with_owner_and_malefactor)),
],
)
def test_user_can_use_only_own_uploads(
self,
src_resource_id: int | None,
archive_path: Path,
owner: dict,
malefactor: dict,
dst_resource_id: str | None,
endpoint_path: str,
query_params: dict | None,
):
with (
make_sdk_client(owner["username"]) as owner_client,
make_sdk_client(malefactor["username"]) as malefactor_client,
):
params = {"filename": archive_path.name}
pbar = NullProgressReporter()
url = owner_client.api_map.make_endpoint_url(
endpoint_path, kwsub=({"id": src_resource_id} if src_resource_id else None)
)
uploader = Uploader(owner_client)
uploader._tus_start_upload(url, query_params=params)
with uploader._uploading_task(pbar, archive_path.stat().st_size):
upload_name = uploader._upload_file_data_with_tus(
url,
archive_path,
meta=params,
pbar=pbar,
)
query_params = {"filename": upload_name, **(query_params or {})}
# check that malefactor cannot use someone else's uploaded file
url_to_steal = malefactor_client.api_map.make_endpoint_url(
endpoint_path, kwsub=({"id": dst_resource_id} if dst_resource_id else None)
)
self._test_cannot_finish_upload(
malefactor_client, url=url_to_steal, query_params=query_params
)
# check that uploaded file still exists and owner can finish started process
self._test_can_finish_upload(owner_client, url=url, query_params=query_params)
+42
View File
@@ -0,0 +1,42 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
from http import HTTPStatus
import pytest
from shared.utils.config import make_api_client, put_method
@pytest.mark.usefixtures("restore_db_per_class")
class TestGetServer:
def test_can_retrieve_about_unauthenticated(self):
with make_api_client(user=None, password=None) as api_client:
data, response = api_client.server_api.retrieve_about()
assert response.status == HTTPStatus.OK
assert data.version
def test_can_retrieve_formats(self, admin_user: str):
with make_api_client(admin_user) as api_client:
data, response = api_client.server_api.retrieve_annotation_formats()
assert response.status == HTTPStatus.OK
assert len(data.importers) != 0
assert len(data.exporters) != 0
def test_method_not_allowed_for_existing_route(self, admin_user: str):
response = put_method(admin_user, "server/annotation/formats", data=None)
assert response.status_code == HTTPStatus.METHOD_NOT_ALLOWED
@pytest.mark.usefixtures("restore_db_per_class")
class TestGetSchema:
def test_can_get_schema_unauthenticated(self):
with make_api_client(user=None, password=None) as api_client:
data, response = api_client.schema_api.retrieve()
assert response.status == HTTPStatus.OK
assert data
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+241
View File
@@ -0,0 +1,241 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import base64
from http import HTTPStatus
import pytest
from shared.utils.config import BASE_URL, make_api_client
from shared.utils.helpers import generate_image_file
@pytest.mark.usefixtures("restore_db_per_function")
@pytest.mark.usefixtures("restore_cvat_data_per_function")
@pytest.mark.usefixtures("restore_redis_ondisk_per_function")
@pytest.mark.usefixtures("restore_redis_inmem_per_function")
class TestTUSUpload:
"""Integration tests for TUS resumable upload protocol"""
_USERNAME = "admin1"
@pytest.fixture
def fxt_task(self):
"""Fixture to create a task for TUS upload tests"""
with make_api_client(self._USERNAME) as api_client:
task, response = api_client.tasks_api.create({"name": "test TUS upload"})
assert response.status == HTTPStatus.CREATED
return task
def _call_tus_endpoint(self, method, path, headers=None, body=None, check_status=True):
"""Call a low-level TUS endpoint via ApiClient"""
headers = headers or {}
with make_api_client(self._USERNAME) as api_client:
api_client.update_params_for_auth(headers=headers, queries=[], method=method)
response = api_client.request(
method,
path,
headers=headers,
body=body,
_parse_response=False,
_check_status=check_status,
)
return response
def _start_upload(self, task_id, file_size, filename):
"""Start TUS upload and return location URL"""
metadata = f"filename {base64.b64encode(filename.encode()).decode()}"
response = self._call_tus_endpoint(
"POST",
BASE_URL + f"/api/tasks/{task_id}/data/",
headers={
"Upload-Length": str(file_size),
"Upload-Metadata": metadata,
"Tus-Resumable": "1.0.0",
},
)
assert response.status == HTTPStatus.CREATED
location = response.headers.get("Location")
assert location is not None
return location
def _upload_chunk(self, location, offset, data, check_status=True):
"""Upload a chunk via TUS PATCH request"""
response = self._call_tus_endpoint(
"PATCH",
location,
headers={
"Upload-Offset": str(offset),
"Content-Type": "application/offset+octet-stream",
"Tus-Resumable": "1.0.0",
},
body=data,
check_status=check_status,
)
return response
def _get_upload_offset(self, location):
"""Get current upload offset via TUS HEAD request"""
response = self._call_tus_endpoint(
"HEAD",
location,
headers={"Tus-Resumable": "1.0.0"},
)
assert response.status == HTTPStatus.OK
offset = response.headers.get("Upload-Offset")
assert offset is not None
return int(offset)
def test_can_upload_file_via_tus_in_single_chunk(self, fxt_task):
"""Test uploading a complete file in one chunk"""
task = fxt_task
image_file = generate_image_file("test_image.jpg", size=(100, 100))
image_data = image_file.getvalue()
file_size = len(image_data)
location = self._start_upload(task.id, file_size, "test_image.jpg")
response = self._upload_chunk(location, 0, image_data)
assert response.status == HTTPStatus.NO_CONTENT
assert int(response.headers.get("Upload-Offset")) == file_size
def test_upload_offset_is_updated_incrementally(self, fxt_task):
"""Test offset updates correctly after each small chunk"""
task = fxt_task
image_file = generate_image_file("test_image.jpg", size=(150, 150))
image_data = image_file.getvalue()
file_size = len(image_data)
chunk_size = 1024
assert file_size > chunk_size, (
f"Image size {file_size} must be larger than chunk size {chunk_size} "
"to properly test incremental uploads"
)
location = self._start_upload(task.id, file_size, "test_image.jpg")
offset = 0
while offset < file_size:
chunk_end = min(offset + chunk_size, file_size)
chunk = image_data[offset:chunk_end]
response = self._upload_chunk(location, offset, chunk)
assert response.status == HTTPStatus.NO_CONTENT
new_offset = int(response.headers.get("Upload-Offset"))
assert new_offset == chunk_end
# Verify via HEAD only if not complete (file gets cleaned after completion)
if new_offset < file_size:
head_offset = self._get_upload_offset(location)
assert head_offset == new_offset
offset = new_offset
def test_cannot_upload_with_wrong_offset(self, fxt_task):
"""Test that upload fails if offset doesn't match"""
task = fxt_task
# Generate test data (no need for valid image since upload won't complete)
file_size = 10000
image_data = b"\x00" * file_size
location = self._start_upload(task.id, file_size, "test_image.jpg")
# Upload first chunk
chunk1 = image_data[:1000]
response = self._upload_chunk(location, 0, chunk1)
assert response.status == HTTPStatus.NO_CONTENT
# Get offset from response (file is not complete yet)
current_offset = int(response.headers.get("Upload-Offset"))
assert current_offset == 1000
# Try wrong offset below expected (should be 1000, we send 500)
chunk2 = image_data[1000:2000]
response = self._upload_chunk(location, 500, chunk2, check_status=False)
assert response.status == HTTPStatus.CONFLICT
# Try wrong offset above expected (should be 1000, we send 1500)
response = self._upload_chunk(location, 1500, chunk2, check_status=False)
assert response.status == HTTPStatus.CONFLICT
def test_cannot_upload_chunk_exceeding_file_size(self, fxt_task):
"""Test that server rejects chunks whose end exceeds file size"""
task = fxt_task
# Generate test data (no need for valid image since upload won't complete)
file_size = 10000
image_data = b"\x00" * file_size
location = self._start_upload(task.id, file_size, "test_image.jpg")
chunk1 = image_data[:1000]
response = self._upload_chunk(location, 0, chunk1)
assert response.status == HTTPStatus.NO_CONTENT
assert int(response.headers.get("Upload-Offset")) == 1000
# Try to upload chunk that would exceed file size
# offset=1000, chunk size would make end_offset > file_size
oversized_chunk = b"x" * (file_size - 500) # This will go beyond file_size
response = self._upload_chunk(location, 1000, oversized_chunk, check_status=False)
assert response.status == HTTPStatus.REQUEST_ENTITY_TOO_LARGE
def test_get_upload_offset_after_complete_upload(self, fxt_task):
"""Test uploading a complete file in one chunk"""
task = fxt_task
image_file = generate_image_file("test_image.jpg", size=(100, 100))
image_data = image_file.getvalue()
file_size = len(image_data)
location = self._start_upload(task.id, file_size, "test_image.jpg")
current_offset = self._get_upload_offset(location)
assert current_offset == 0
response = self._upload_chunk(location, 0, image_data)
assert response.status == HTTPStatus.NO_CONTENT
assert int(response.headers.get("Upload-Offset")) == file_size
# After completing the download, the HEAD request should return 404.
head_response = self._call_tus_endpoint(
"HEAD",
location,
headers={"Tus-Resumable": "1.0.0"},
check_status=False,
)
assert head_response.status == HTTPStatus.NOT_FOUND
# We verify that tus_file.meta_file.init_from_file() runs correctly after data validation.
# was 500 (Internal Server Error) because tus_file.meta_file.init_from_file() was called before data validation.
def test_upload_without_trailing_slash(self, fxt_task):
"""Test uploading without the trailing slash in the initial URL."""
task = fxt_task
response = self._call_tus_endpoint(
"POST",
BASE_URL + f"/api/tasks/{task.id}/data",
headers={
"Upload-Length": "1000",
"Upload-Metadata": f"filename {base64.b64encode('test_image.jpg'.encode()).decode()}",
"Tus-Resumable": "1.0.0",
},
)
assert response.status == HTTPStatus.CREATED
location = response.headers.get("Location")
assert location is not None
response = self._upload_chunk(location, 0, b"xxxxx")
assert response.status == HTTPStatus.NO_CONTENT
assert int(response.headers.get("Upload-Offset")) == 5
+172
View File
@@ -0,0 +1,172 @@
# Copyright (C) 2021-2022 Intel Corporation
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import json
from http import HTTPStatus
from typing import Literal
import pytest
from cvat_sdk.api_client import models
from cvat_sdk.api_client.api_client import ApiClient, Endpoint
from cvat_sdk.core.helpers import get_paginated_collection
from deepdiff import DeepDiff
from shared.utils.config import make_api_client
from .utils import CollectionSimpleFilterTestBase
@pytest.mark.usefixtures("restore_db_per_class")
class TestGetUsers:
def _test_can_see(
self,
user,
data,
id_: Literal["self"] | int | None = None,
*,
exclude_paths="",
**kwargs,
):
with make_api_client(user) as api_client:
# TODO: refactor into several functions
if id_ == "self":
_, response = api_client.users_api.retrieve_self(**kwargs, _parse_response=False)
assert response.status == HTTPStatus.OK
response_data = json.loads(response.data)
elif id_ is None:
response_data = get_paginated_collection(
api_client.users_api.list_endpoint, return_json=True, **kwargs
)
else:
_, response = api_client.users_api.retrieve(id_, **kwargs, _parse_response=False)
assert response.status == HTTPStatus.OK
response_data = json.loads(response.data)
assert DeepDiff(data, response_data, ignore_order=True, exclude_paths=exclude_paths) == {}
def _test_cannot_see(self, user, id_: Literal["self"] | int | None = None, **kwargs):
with make_api_client(user) as api_client:
# TODO: refactor into several functions
if id_ == "self":
_, response = api_client.users_api.retrieve_self(
**kwargs, _parse_response=False, _check_status=False
)
elif id_ is None:
_, response = api_client.users_api.list(
**kwargs, _parse_response=False, _check_status=False
)
else:
_, response = api_client.users_api.retrieve(
id_, **kwargs, _parse_response=False, _check_status=False
)
assert response.status == HTTPStatus.FORBIDDEN
def test_admin_can_see_all_others(self, users):
exclude_paths = [f"root[{i}]['last_login']" for i in range(len(users))]
self._test_can_see("admin2", users.raw, exclude_paths=exclude_paths)
def test_everybody_can_see_self(self, users_by_name):
for user, data in users_by_name.items():
self._test_can_see(
user, data, id_="self", exclude_paths=["root['last_login']", "root['key']"]
)
def test_non_members_cannot_see_list_of_members(self):
self._test_cannot_see("user2", org="org1")
def test_non_admin_cannot_see_others(self, users):
non_admins = (v for v in users if not v["is_superuser"])
user = next(non_admins)["username"]
user_id = next(non_admins)["id"]
self._test_cannot_see(user, id_=user_id)
def test_all_members_can_see_list_of_members(self, find_users, users):
org_members = [user["username"] for user in find_users(org=1)]
available_fields = ["url", "id", "username", "first_name", "last_name"]
data = [
dict(filter(lambda row: row[0] in available_fields, user.items()))
for user in users
if user["username"] in org_members
]
for member in org_members:
self._test_can_see(member, data, org="org1")
class TestUsersListFilters(CollectionSimpleFilterTestBase):
@pytest.fixture(autouse=True)
def setup(self, restore_db_per_class, admin_user, users):
self.user = admin_user
self.samples = users
def _get_endpoint(self, api_client: ApiClient) -> Endpoint:
return api_client.users_api.list_endpoint
@pytest.mark.parametrize(
"field",
("is_active", "username"),
)
def test_can_use_simple_filter_for_object_list(self, field):
return super()._test_can_use_simple_filter_for_object_list(field)
@pytest.mark.usefixtures("restore_db_per_function")
class TestPatchUsers:
def test_can_update_own_username(self, find_users, admin_user):
db_user = find_users(privilege="user")[0]
old_username = db_user["username"]
new_username = f"{old_username}_renamed"
with make_api_client(old_username) as api_client:
updated_user, response = api_client.users_api.partial_update(
db_user["id"],
patched_user_request=models.PatchedUserRequest(username=new_username),
)
assert response.status == HTTPStatus.OK
assert updated_user.username == new_username
with make_api_client(admin_user) as api_client:
user, response = api_client.users_api.retrieve(db_user["id"])
assert response.status == HTTPStatus.OK
assert user.id == db_user["id"]
assert user.username == new_username
def test_cannot_update_username_to_invalid_value(self, find_users):
user = find_users(privilege="user")[0]
with make_api_client(user["username"]) as api_client:
# Use a raw API call because the generated SDK model validates username
# format client-side and would reject this value before the server sees it.
_, response = api_client.call_api(
f"/api/users/{user['id']}",
method="PATCH",
body={"username": "bad user"},
header_params={"Content-Type": "application/json"},
_parse_response=False,
_check_status=False,
)
assert response.status == HTTPStatus.BAD_REQUEST
assert "username" in json.loads(response.data)
def test_non_admin_cannot_update_another_user_username(self, find_users):
user = find_users(privilege="user")[0]
another_user = find_users(privilege="user", exclude_username=user["username"])[0]
with make_api_client(user["username"]) as api_client:
_, response = api_client.users_api.partial_update(
another_user["id"],
patched_user_request=models.PatchedUserRequest(
username=f'{another_user["username"]}_renamed',
),
_parse_response=False,
_check_status=False,
)
assert response.status == HTTPStatus.FORBIDDEN
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,829 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import json
from http import HTTPStatus
from time import sleep, time
import pytest
from deepdiff import DeepDiff
from shared.fixtures.data import Container
from shared.fixtures.init import CVAT_ROOT_DIR
from shared.utils.config import delete_method, get_method, patch_method, post_method
from .utils import export_task_backup, export_task_dataset
# Testing webhook functionality:
# - webhook_receiver container receive post request and return responses with the same body
# - CVAT save response body for each delivery
#
# So idea of this testing system is quite simple:
# 1) trigger some webhook
# 2) check that webhook is sent by checking value of `response` field for the last delivery of this webhook
# https://docs.pytest.org/en/7.1.x/example/markers.html#marking-whole-classes-or-modules
pytestmark = [pytest.mark.with_external_services]
def _read_receiver_env():
env_data = {}
with open(CVAT_ROOT_DIR / "tests/python/webhook_receiver/.env", "r") as f:
for line in f:
name, value = tuple(line.strip().split("="))
env_data[name] = value
return env_data
def target_url():
env_data = _read_receiver_env()
return (
f'http://{env_data["SERVER_HOST"]}:{env_data["SERVER_PORT"]}/{env_data["PAYLOAD_ENDPOINT"]}'
)
def webhook_spec(events, project_id=None, webhook_type="organization"):
# Django URL field doesn't allow to use http://webhooks:2020/payload (using alias)
# So we forced to use ip address of webhook receiver container
return {
"target_url": target_url(),
"content_type": "application/json",
"enable_ssl": False,
"events": events,
"is_active": True,
"project_id": project_id,
"type": webhook_type,
}
def create_webhook(events, webhook_type, project_id=None, org_id=""):
assert (webhook_type == "project" and project_id is not None) or (
webhook_type == "organization" and org_id
)
response = post_method(
"admin1", "webhooks", webhook_spec(events, project_id, webhook_type), org_id=org_id
)
assert response.status_code == HTTPStatus.CREATED
return response.json()
def get_deliveries(webhook_id, expected_count=1, *, timeout: int = 60):
start_time = time()
delivery_response = {}
while True:
response = get_method("admin1", f"webhooks/{webhook_id}/deliveries")
assert response.status_code == HTTPStatus.OK
deliveries = response.json()
if deliveries["count"] == expected_count:
raw_deliver_response = deliveries["results"][0]["response"]
delivery_response = json.loads(raw_deliver_response) if raw_deliver_response else {}
break
if time() - start_time > timeout:
raise TimeoutError("Failed to get deliveries within the specified time interval")
sleep(1)
return deliveries, delivery_response
@pytest.mark.usefixtures("restore_db_per_function")
class TestWebhookProjectEvents:
def test_webhook_update_project_name(self):
response = post_method("admin1", "projects", {"name": "project"})
assert response.status_code == HTTPStatus.CREATED
project = response.json()
events = ["update:project"]
webhook = create_webhook(events, "project", project_id=project["id"])
patch_data = {"name": "new_project_name"}
response = patch_method("admin1", f"projects/{project['id']}", patch_data)
assert response.status_code == HTTPStatus.OK
response = get_method("admin1", f"webhooks/{webhook['id']}/deliveries")
assert response.status_code == HTTPStatus.OK
deliveries, payload = get_deliveries(webhook["id"])
assert deliveries["count"] == 1
assert payload["event"] == events[0]
assert payload["sender"]["username"] == "admin1"
assert payload["before_update"]["name"] == project["name"]
project.update(patch_data)
assert (
DeepDiff(
payload["project"],
project,
ignore_order=True,
exclude_paths=["root['updated_date']"],
)
== {}
)
def test_webhook_create_and_delete_project_in_organization(self, organizations):
org_id = list(organizations)[0]["id"]
events = ["create:project", "delete:project"]
webhook = create_webhook(events, "organization", org_id=org_id)
response = post_method("admin1", "projects", {"name": "project_name"}, org_id=org_id)
assert response.status_code == HTTPStatus.CREATED
project = response.json()
deliveries, create_payload = get_deliveries(webhook["id"])
assert deliveries["count"] == 1
response = delete_method("admin1", f"projects/{project['id']}", org_id=org_id)
assert response.status_code == HTTPStatus.NO_CONTENT
deliveries, delete_payload = get_deliveries(webhook["id"], 2)
assert deliveries["count"] == 2
assert create_payload["event"] == "create:project"
assert delete_payload["event"] == "delete:project"
assert (
DeepDiff(
create_payload["project"],
project,
ignore_order=True,
exclude_paths=["root['updated_date']"],
)
== {}
)
assert (
DeepDiff(
delete_payload["project"],
project,
ignore_order=True,
exclude_paths=["root['updated_date']"],
)
== {}
)
@pytest.mark.usefixtures("restore_db_per_function")
class TestWebhookIntersection:
# Test case description:
# few webhooks are triggered by the same event
# In this case we need to check that CVAT will sent
# the right number of payloads to the target url
def test_project_and_organization_webhooks_intersection(self, organizations):
org_id = list(organizations)[0]["id"]
post_data = {"name": "project_name"}
response = post_method("admin1", "projects", post_data, org_id=org_id)
assert response.status_code == HTTPStatus.CREATED
events = ["update:project"]
project_id = response.json()["id"]
webhook_id_1 = create_webhook(events, "organization", org_id=org_id)["id"]
webhook_id_2 = create_webhook(events, "project", project_id=project_id, org_id=org_id)["id"]
patch_data = {"name": "new_project_name"}
response = patch_method("admin1", f"projects/{project_id}", patch_data)
assert response.status_code == HTTPStatus.OK
deliveries_1, payload_1 = get_deliveries(webhook_id_1)
deliveries_2, payload_2 = get_deliveries(webhook_id_2)
assert deliveries_1["count"] == deliveries_2["count"] == 1
assert payload_1["project"]["name"] == payload_2["project"]["name"] == patch_data["name"]
assert (
payload_1["before_update"]["name"]
== payload_2["before_update"]["name"]
== post_data["name"]
)
assert payload_1["webhook_id"] == webhook_id_1
assert payload_2["webhook_id"] == webhook_id_2
assert deliveries_1["results"][0]["webhook_id"] == webhook_id_1
assert deliveries_2["results"][0]["webhook_id"] == webhook_id_2
def test_two_project_webhooks_intersection(self):
post_data = {"name": "project_name"}
response = post_method("admin1", "projects", post_data)
assert response.status_code == HTTPStatus.CREATED
project_id = response.json()["id"]
events_1 = ["create:task", "update:issue"]
events_2 = ["create:task", "create:issue"]
webhook_id_1 = create_webhook(events_1, "project", project_id=project_id)["id"]
webhook_id_2 = create_webhook(events_2, "project", project_id=project_id)["id"]
post_data = {"name": "project_name", "project_id": project_id}
response = post_method("admin1", "tasks", post_data)
assert response.status_code == HTTPStatus.CREATED
deliveries_1, payload_1 = get_deliveries(webhook_id_1)
deliveries_2, payload_2 = get_deliveries(webhook_id_2)
assert deliveries_1["count"] == deliveries_2["count"] == 1
assert payload_1["event"] == payload_2["event"] == "create:task"
assert payload_1["task"]["name"] == payload_2["task"]["name"] == post_data["name"]
assert payload_1["webhook_id"] == webhook_id_1
assert payload_2["webhook_id"] == webhook_id_2
def test_two_organization_webhook_intersection(self, organizations):
org_id = list(organizations)[0]["id"]
events_1 = ["create:project", "update:membership"]
events_2 = ["create:project", "update:job"]
webhook_id_1 = create_webhook(events_1, "organization", org_id=org_id)["id"]
webhook_id_2 = create_webhook(events_2, "organization", org_id=org_id)["id"]
post_data = {"name": "project_name"}
response = post_method("admin1", "projects", post_data, org_id=org_id)
assert response.status_code == HTTPStatus.CREATED
project = response.json()
deliveries_1, payload_1 = get_deliveries(webhook_id_1)
deliveries_2, payload_2 = get_deliveries(webhook_id_2)
assert deliveries_1["count"] == deliveries_2["count"] == 1
assert payload_1["webhook_id"] == webhook_id_1
assert payload_2["webhook_id"] == webhook_id_2
assert (
DeepDiff(
payload_1["project"],
project,
ignore_order=True,
exclude_paths=["root['updated_date']"],
)
== {}
)
assert (
DeepDiff(
payload_2["project"],
project,
ignore_order=True,
exclude_paths=["root['updated_date']"],
)
== {}
)
@pytest.mark.usefixtures("restore_db_per_function")
class TestWebhookTaskEvents:
def test_webhook_update_task_assignee(self, users, tasks):
task_id, project_id = next(
(task["id"], task["project_id"])
for task in tasks
if task["project_id"] is not None
and task["organization"] is None
and task["assignee"] is not None
)
assignee_id = next(
user["id"] for user in users if user["id"] != tasks[task_id]["assignee"]["id"]
)
webhook_id = create_webhook(["update:task"], "project", project_id=project_id)["id"]
patch_data = {"assignee_id": assignee_id}
response = patch_method("admin1", f"tasks/{task_id}", patch_data)
assert response.status_code == HTTPStatus.OK
deliveries, payload = get_deliveries(webhook_id=webhook_id)
assert deliveries["count"] == 1
assert payload["before_update"]["assignee"]["id"] == tasks[task_id]["assignee"]["id"]
assert payload["task"]["assignee"]["id"] == assignee_id
def test_webhook_create_and_delete_task(self, organizations):
org_id = list(organizations)[0]["id"]
events = ["create:task", "delete:task"]
webhook = create_webhook(events, "organization", org_id=org_id)
post_data = {"name": "task_name", "labels": [{"name": "label_0"}]}
response = post_method("admin1", "tasks", post_data, org_id=org_id)
assert response.status_code == HTTPStatus.CREATED
task = response.json()
deliveries, create_payload = get_deliveries(webhook["id"])
assert deliveries["count"] == 1
response = delete_method("admin1", f"tasks/{task['id']}", org_id=org_id)
assert response.status_code == HTTPStatus.NO_CONTENT
deliveries, delete_payload = get_deliveries(webhook["id"], 2)
assert deliveries["count"] == 2
assert create_payload["event"] == "create:task"
assert delete_payload["event"] == "delete:task"
# These values cannot be computed if the task has no data
assert create_payload["task"]["jobs"]["completed"] is None
assert create_payload["task"]["jobs"]["validation"] is None
assert task["jobs"]["completed"] == 0
assert task["jobs"]["validation"] == 0
assert delete_payload["task"]["jobs"]["completed"] == 0
assert delete_payload["task"]["jobs"]["validation"] == 0
assert (
DeepDiff(
create_payload["task"],
task,
ignore_order=True,
exclude_paths=["root['updated_date']", "root['jobs']", "root['labels']"],
)
== {}
)
assert (
DeepDiff(
delete_payload["task"],
task,
ignore_order=True,
exclude_paths=["root['updated_date']", "root['jobs']", "root['labels']"],
)
== {}
)
@pytest.mark.usefixtures("restore_db_per_function")
class TestWebhookJobEvents:
def test_webhook_update_job_assignee(self, jobs, tasks, users):
job = next(
job
for job in jobs
if job["assignee"] is None and tasks[job["task_id"]]["organization"] is not None
)
org_id = tasks[job["task_id"]]["organization"]
webhook_id = create_webhook(["update:job"], "organization", org_id=org_id)["id"]
patch_data = {"assignee": list(users)[0]["id"]}
response = patch_method("admin1", f"jobs/{job['id']}", patch_data, org_id=org_id)
assert response.status_code == HTTPStatus.OK
deliveries, payload = get_deliveries(webhook_id)
assert deliveries["count"] == 1
assert payload["before_update"]["assignee"] is None
assert payload["job"]["assignee"]["id"] == patch_data["assignee"]
def test_webhook_update_job_stage(self, jobs, tasks):
stages = {"annotation", "validation", "acceptance"}
job = next(job for job in jobs if tasks[job["task_id"]]["organization"] is not None)
org_id = tasks[job["task_id"]]["organization"]
webhook_id = create_webhook(["update:job"], "organization", org_id=org_id)["id"]
patch_data = {"stage": (stages - {job["stage"]}).pop()}
response = patch_method("admin1", f"jobs/{job['id']}", patch_data, org_id=org_id)
assert response.status_code == HTTPStatus.OK
deliveries, payload = get_deliveries(webhook_id)
assert deliveries["count"] == 1
assert payload["before_update"]["stage"] == job["stage"]
assert payload["job"]["stage"] == patch_data["stage"]
def test_webhook_update_job_state(self, jobs, tasks):
states = {"new", "in progress", "rejected", "completed"}
job = next(
job
for job in jobs
if tasks[job["task_id"]]["organization"] is not None and job["state"] == "in progress"
)
org_id = tasks[job["task_id"]]["organization"]
webhook_id = create_webhook(["update:job"], "organization", org_id=org_id)["id"]
patch_data = {"state": (states - {job["state"]}).pop()}
response = patch_method("admin1", f"jobs/{job['id']}", patch_data, org_id=org_id)
assert response.status_code == HTTPStatus.OK
deliveries, payload = get_deliveries(webhook_id)
assert deliveries["count"] == 1
assert payload["before_update"]["state"] == job["state"]
assert payload["job"]["state"] == patch_data["state"]
@pytest.mark.usefixtures("restore_db_per_function")
class TestWebhookIssueEvents:
def test_webhook_update_issue_resolved(self, issues, jobs, tasks):
issue = next(
issue
for issue in issues
if tasks[jobs[issue["job"]]["task_id"]]["organization"] is not None
)
org_id = tasks[jobs[issue["job"]]["task_id"]]["organization"]
webhook_id = create_webhook(["update:issue"], "organization", org_id=org_id)["id"]
patch_data = {"resolved": not issue["resolved"]}
response = patch_method("admin1", f"issues/{issue['id']}", patch_data, org_id=org_id)
assert response.status_code == HTTPStatus.OK
deliveries, payload = get_deliveries(webhook_id)
assert deliveries["count"] == 1
assert payload["before_update"]["resolved"] == issue["resolved"]
assert payload["issue"]["resolved"] == patch_data["resolved"]
def test_webhook_update_issue_position(self, issues, jobs, tasks):
issue = next(
issue
for issue in issues
if tasks[jobs[issue["job"]]["task_id"]]["organization"] is not None
)
org_id = tasks[jobs[issue["job"]]["task_id"]]["organization"]
webhook_id = create_webhook(["update:issue"], "organization", org_id=org_id)["id"]
patch_data = {"position": [0, 1, 2, 3]}
response = patch_method("admin1", f"issues/{issue['id']}", patch_data, org_id=org_id)
assert response.status_code == HTTPStatus.OK
deliveries, payload = get_deliveries(webhook_id)
assert deliveries["count"] == 1
assert payload["before_update"]["position"] == issue["position"]
assert payload["issue"]["position"] == patch_data["position"]
@pytest.mark.parametrize("org_id", (2,))
def test_webhook_create_and_delete_issue(self, org_id: int, jobs, tasks):
job_id = next(job["id"] for job in jobs if tasks[job["task_id"]]["organization"] == org_id)
events = ["create:issue", "delete:issue"]
webhook = create_webhook(events, "organization", org_id=org_id)
post_data = {"frame": 0, "position": [0, 1, 2, 3], "job": job_id, "message": "issue_msg"}
response = post_method("admin1", "issues", post_data, org_id=org_id)
assert response.status_code == HTTPStatus.CREATED
issue = response.json()
deliveries, create_payload = get_deliveries(webhook["id"])
assert deliveries["count"] == 1
response = delete_method("admin1", f"issues/{issue['id']}", org_id=org_id)
assert response.status_code == HTTPStatus.NO_CONTENT
deliveries, delete_payload = get_deliveries(webhook["id"], 2)
assert deliveries["count"] == 2
assert create_payload["event"] == "create:issue"
assert delete_payload["event"] == "delete:issue"
assert (
DeepDiff(
create_payload["issue"],
issue,
ignore_order=True,
exclude_paths=["root['updated_date']", "root['comments']"],
)
== {}
)
assert (
DeepDiff(
delete_payload["issue"],
issue,
ignore_order=True,
exclude_paths=["root['updated_date']", "root['comments']"],
)
== {}
)
@pytest.mark.usefixtures("restore_db_per_function")
class TestWebhookMembershipEvents:
def test_webhook_update_membership_role(self, memberships):
roles = {"worker", "supervisor", "maintainer"}
membership = next(membership for membership in memberships if membership["role"] != "owner")
org_id = membership["organization"]
webhook_id = create_webhook(["update:membership"], "organization", org_id=org_id)["id"]
patch_data = {"role": (roles - {membership["role"]}).pop()}
response = patch_method(
"admin1", f"memberships/{membership['id']}", patch_data, org_id=org_id
)
assert response.status_code == HTTPStatus.OK
deliveries, payload = get_deliveries(webhook_id)
assert deliveries["count"] == 1
assert payload["before_update"]["role"] == membership["role"]
assert payload["membership"]["role"] == patch_data["role"]
def test_webhook_delete_membership(self, memberships):
membership = next(membership for membership in memberships if membership["role"] != "owner")
org_id = membership["organization"]
webhook_id = create_webhook(["delete:membership"], "organization", org_id=org_id)["id"]
response = delete_method("admin1", f"memberships/{membership['id']}", org_id=org_id)
assert response.status_code == HTTPStatus.NO_CONTENT
deliveries, payload = get_deliveries(webhook_id)
assert deliveries["count"] == 1
assert (
DeepDiff(
payload["membership"],
membership,
ignore_order=True,
exclude_paths=["root['updated_date']", "root['invitation']"],
)
== {}
)
@pytest.mark.usefixtures("restore_db_per_function")
class TestWebhookOrganizationEvents:
def test_webhook_update_organization_name(self, organizations):
org_id = list(organizations)[0]["id"]
webhook_id = create_webhook(["update:organization"], "organization", org_id=org_id)["id"]
patch_data = {"name": "new_org_name"}
patch_method("admin1", f"organizations/{org_id}", patch_data, org_id=org_id)
deliveries, payload = get_deliveries(webhook_id)
assert deliveries["count"] == 1
assert payload["before_update"]["name"] == organizations[org_id]["name"]
assert payload["organization"]["name"] == patch_data["name"]
@pytest.mark.usefixtures("restore_db_per_function")
class TestWebhookCommentEvents:
def test_webhook_update_comment_message(self, comments, issues, jobs, tasks):
org_comments = list(
(comment, tasks[jobs[issues[comment["issue"]]["job"]]["task_id"]]["organization"])
for comment in comments
)
comment, org_id = next(
((comment, org_id) for comment, org_id in org_comments if org_id is not None)
)
webhook_id = create_webhook(["update:comment"], "organization", org_id=org_id)["id"]
patch_data = {"message": "new comment message"}
response = patch_method("admin1", f"comments/{comment['id']}", patch_data, org_id=org_id)
assert response.status_code == HTTPStatus.OK
deliveries, payload = get_deliveries(webhook_id)
assert deliveries["count"] == 1
assert payload["before_update"]["message"] == comment["message"]
comment.update(patch_data)
assert (
DeepDiff(
payload["comment"],
comment,
ignore_order=True,
exclude_paths=["root['updated_date']"],
)
== {}
)
def test_webhook_create_and_delete_comment(self, issues, jobs, tasks):
issue = next(
issue
for issue in issues
if tasks[jobs[issue["job"]]["task_id"]]["organization"] is not None
)
org_id = tasks[jobs[issue["job"]]["task_id"]]["organization"]
events = ["create:comment", "delete:comment"]
webhook_id = create_webhook(events, "organization", org_id=org_id)["id"]
post_data = {"issue": issue["id"], "message": "new comment message"}
response = post_method("admin1", "comments", post_data, org_id=org_id)
assert response.status_code == HTTPStatus.CREATED
create_deliveries, create_payload = get_deliveries(webhook_id)
comment_id = response.json()["id"]
response = delete_method("admin1", f"comments/{comment_id}", org_id=org_id)
assert response.status_code == HTTPStatus.NO_CONTENT
delete_deliveries, delete_payload = get_deliveries(webhook_id, 2)
assert create_deliveries["count"] == 1
assert delete_deliveries["count"] == 2
assert create_payload["event"] == "create:comment"
assert delete_payload["event"] == "delete:comment"
assert (
create_payload["comment"]["message"]
== delete_payload["comment"]["message"]
== post_data["message"]
)
@pytest.mark.usefixtures("restore_db_per_class")
class TestGetWebhookDeliveries:
def test_not_project_staff_cannot_get_webhook(self, projects, users):
user, project = next(
(user, project)
for user in users
if "user" in user["groups"]
for project in projects
if project["owner"]["id"] != user["id"]
)
webhook = create_webhook(["create:task"], "project", project_id=project["id"])
owner = next(user for user in users if user["id"] == project["owner"]["id"])
response = post_method(owner["username"], f"webhooks/{webhook['id']}/ping", {})
assert response.status_code == HTTPStatus.OK
delivery_id = response.json()["id"]
response = get_method(user["username"], f"webhooks/{webhook['id']}/deliveries")
assert response.status_code == HTTPStatus.FORBIDDEN
response = get_method(
user["username"], f"webhooks/{webhook['id']}/deliveries/{delivery_id}"
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.usefixtures("restore_db_per_function")
class TestWebhookPing:
def test_ping_webhook(self, projects):
project_id = list(projects)[0]["id"]
webhook = create_webhook(["create:task"], "project", project_id=project_id)
response = post_method("admin1", f"webhooks/{webhook['id']}/ping", {})
assert response.status_code == HTTPStatus.OK
deliveries, payload = get_deliveries(webhook["id"])
assert deliveries["count"] == 1
assert (
DeepDiff(
payload["webhook"],
webhook,
ignore_order=True,
exclude_paths=["root['updated_date']"],
)
== {}
)
def test_not_project_staff_cannot_ping(self, projects, users):
user, project = next(
(user, project)
for user in users
if "user" in user["groups"]
for project in projects
if project["owner"]["id"] != user["id"]
)
webhook = create_webhook(["create:task"], "project", project_id=project["id"])
response = post_method(user["username"], f"webhooks/{webhook['id']}/ping", {})
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.usefixtures("restore_db_per_function")
class TestWebhookRedelivery:
def test_webhook_redelivery(self, projects):
project = list(projects)[0]
webhook_id = create_webhook(["update:project"], "project", project_id=project["id"])["id"]
patch_data = {"name": "new_project_name"}
response = patch_method("admin1", f"projects/{project['id']}", patch_data)
assert response.status_code == HTTPStatus.OK
deliveries_1, payload_1 = get_deliveries(webhook_id)
delivery_id = deliveries_1["results"][0]["id"]
response = post_method(
"admin1", f"webhooks/{webhook_id}/deliveries/{delivery_id}/redelivery", {}
)
assert response.status_code == HTTPStatus.OK
deliveries_2, payload_2 = get_deliveries(webhook_id, 2)
assert deliveries_1["count"] == 1
assert deliveries_2["count"] == 2
assert deliveries_1["results"][0]["redelivery"] is False
assert deliveries_2["results"][0]["redelivery"] is True
project.update(patch_data)
assert (
DeepDiff(
payload_1["project"],
project,
ignore_order=True,
exclude_paths=["root['updated_date']"],
)
== {}
)
assert (
DeepDiff(
payload_2["project"],
project,
ignore_order=True,
exclude_paths=["root['updated_date']"],
)
== {}
)
def test_not_project_staff_cannot_redeliver(self, projects, users):
user, project = next(
(user, project)
for user in users
if "user" in user["groups"]
for project in projects
if project["owner"]["id"] != user["id"]
)
webhook = create_webhook(["create:task"], "project", project_id=project["id"])
owner = next(user for user in users if user["id"] == project["owner"]["id"])
response = post_method(owner["username"], f"webhooks/{webhook['id']}/ping", {})
assert response.status_code == HTTPStatus.OK
delivery_id = response.json()["id"]
response = post_method(
user["username"], f"webhooks/{webhook['id']}/deliveries/{delivery_id}/redelivery", {}
)
assert response.status_code == HTTPStatus.FORBIDDEN
def _task_with_data_in_org(tasks: Container) -> dict:
return next(
t
for t in tasks
if t["mode"] in ("annotation", "interpolation")
and not t["validation_mode"]
and t["organization"] is not None
)
@pytest.mark.usefixtures("restore_db_per_function")
class TestWebhookExportEvents:
def test_webhook_create_export_for_task(self, tasks: Container) -> None:
task = _task_with_data_in_org(tasks)
webhook_id = create_webhook(
events=["create:export"], webhook_type="organization", org_id=task["organization"]
)["id"]
export_task_dataset("admin1", id=task["id"], save_images=False, download_result=False)
_, payload = get_deliveries(webhook_id)
assert payload["event"] == "create:export"
assert payload["status"] == "succeeded"
assert payload["target"] == "task"
assert payload["target_id"] == task["id"]
@pytest.mark.usefixtures("restore_db_per_function")
class TestWebhookBackupEvents:
def test_webhook_create_backup_for_task(self, tasks: Container) -> None:
task = _task_with_data_in_org(tasks)
webhook_id = create_webhook(
events=["create:backup"], webhook_type="organization", org_id=task["organization"]
)["id"]
export_task_backup("admin1", id=task["id"], download_result=False)
_, payload = get_deliveries(webhook_id)
assert payload["event"] == "create:backup"
assert payload["status"] == "succeeded"
assert payload["target"] == "task"
assert payload["target_id"] == task["id"]
+40
View File
@@ -0,0 +1,40 @@
# Copyright (C) 2025 Intel Corporation
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import io
from collections.abc import Callable
from contextlib import closing
from typing import ClassVar
import attrs
from PIL import Image
from shared.tasks.base import TaskSpecBase
from shared.tasks.enums import SourceDataType
from shared.utils.helpers import read_video_file
@attrs.define
class VideoTaskSpec(TaskSpecBase):
source_data_type: ClassVar[SourceDataType] = SourceDataType.video
_get_video_file: Callable[[], io.IOBase] = attrs.field(kw_only=True)
def read_frame(self, i: int) -> Image.Image:
with closing(read_video_file(self._get_video_file())) as reader:
for _ in range(i + 1):
frame = next(reader)
return frame
@attrs.define
class ImagesTaskSpec(TaskSpecBase):
source_data_type: ClassVar[SourceDataType] = SourceDataType.images
_get_frame: Callable[[int], bytes] = attrs.field(kw_only=True)
def read_frame(self, i: int) -> Image.Image:
return Image.open(io.BytesIO(self._get_frame(i)))
+660
View File
@@ -0,0 +1,660 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import json
from abc import ABCMeta, abstractmethod
from collections.abc import Callable, Hashable, Iterable, Iterator, Sequence
from copy import deepcopy
from http import HTTPStatus
from io import BytesIO
from time import sleep
from typing import Any, TypeAlias, TypeVar
import requests
from cvat_sdk.api_client import apis, models
from cvat_sdk.api_client.api.jobs_api import JobsApi
from cvat_sdk.api_client.api.projects_api import ProjectsApi
from cvat_sdk.api_client.api.tasks_api import TasksApi
from cvat_sdk.api_client.api_client import ApiClient, Endpoint
from cvat_sdk.api_client.exceptions import ForbiddenException
from cvat_sdk.core.helpers import get_paginated_collection
from deepdiff import DeepDiff
from urllib3 import HTTPResponse
from shared.utils.config import USER_PASS, make_api_client, post_method
DEFAULT_RETRIES = 50
DEFAULT_INTERVAL = 0.1
def initialize_export(endpoint: Endpoint, *, expect_forbidden: bool = False, **kwargs) -> str:
_, response = endpoint.call_with_http_info(**kwargs, _parse_response=False, _check_status=False)
if expect_forbidden:
assert (
response.status == HTTPStatus.FORBIDDEN
), f"Request should be forbidden, status: {response.status}"
raise ForbiddenException()
assert response.status == HTTPStatus.ACCEPTED, (f"Status: {response.status}", response.data)
# define background request ID returned in the server response
rq_id = json.loads(response.data).get("rq_id")
assert rq_id, "The rq_id parameter was not found in the server response"
return rq_id
def wait_background_request(
api_client: ApiClient,
rq_id: str,
*,
max_retries: int = DEFAULT_RETRIES,
interval: float = DEFAULT_INTERVAL,
) -> tuple[models.Request, HTTPResponse]:
for _ in range(max_retries):
background_request, response = api_client.requests_api.retrieve(rq_id)
assert response.status == HTTPStatus.OK
if background_request.status.value == "finished":
return background_request, response
assert (
background_request.status.value != "failed"
), f"Background request failed with message: {background_request.message}"
sleep(interval)
assert False, (
f"Export process was not finished within allowed time ({interval * max_retries}, sec). "
+ f"Last status was: {background_request.status.value}"
)
def wait_and_download_v2(
api_client: ApiClient,
rq_id: str,
*,
max_retries: int = DEFAULT_RETRIES,
interval: float = DEFAULT_INTERVAL,
) -> bytes:
background_request, _ = wait_background_request(
api_client, rq_id, max_retries=max_retries, interval=interval
)
# return downloaded file in case of local downloading
assert background_request.result_url
headers = api_client.get_common_headers()
query_params = []
api_client.update_params_for_auth(headers=headers, queries=query_params)
assert not query_params # query auth is not expected
response = requests.get(background_request.result_url, headers=headers)
assert response.status_code == HTTPStatus.OK, f"Status: {response.status_code}"
return response.content
def export_v2(
endpoint: Endpoint,
*,
max_retries: int = DEFAULT_RETRIES,
interval: float = DEFAULT_INTERVAL,
expect_forbidden: bool = False,
wait_result: bool = True,
download_result: bool = True,
**kwargs,
) -> bytes | str:
"""Export datasets|annotations|backups using the second version of export API
Args:
endpoint (Endpoint): Export endpoint, will be called only to initialize export process
max_retries (int, optional): Number of retries when checking process status. Defaults to 30.
interval (float, optional): Interval in seconds between retries. Defaults to 0.1.
expect_forbidden (bool, optional): Should export request be forbidden or not. Defaults to False.
download_result (bool, optional): Download exported file. Defaults to True.
Returns:
bytes: The content of the file if downloaded locally.
str: If `wait_result` or `download_result` were False.
"""
# initialize background process and ensure that the first request returns 403 code if request should be forbidden
rq_id = initialize_export(endpoint, expect_forbidden=expect_forbidden, **kwargs)
if not wait_result:
return rq_id
# check status of background process
if download_result:
return wait_and_download_v2(
endpoint.api_client,
rq_id,
max_retries=max_retries,
interval=interval,
)
background_request, _ = wait_background_request(
endpoint.api_client, rq_id, max_retries=max_retries, interval=interval
)
return background_request.id
def export_dataset(
api: ProjectsApi | TasksApi | JobsApi,
*,
save_images: bool,
max_retries: int = 300,
interval: float = DEFAULT_INTERVAL,
format: str = "CVAT for images 1.1", # pylint: disable=redefined-builtin
**kwargs,
) -> bytes | None:
return export_v2(
api.create_dataset_export_endpoint,
max_retries=max_retries,
interval=interval,
save_images=save_images,
format=format,
**kwargs,
)
# FUTURE-TODO: support username: optional, api_client: optional
# tODO: make func signature more userfrendly
def export_project_dataset(username: str, *args, **kwargs) -> bytes | None:
with make_api_client(username) as api_client:
return export_dataset(api_client.projects_api, *args, **kwargs)
def export_task_dataset(username: str, *args, **kwargs) -> bytes | None:
with make_api_client(username) as api_client:
return export_dataset(api_client.tasks_api, *args, **kwargs)
def export_job_dataset(username: str, *args, **kwargs) -> bytes | None:
with make_api_client(username) as api_client:
return export_dataset(api_client.jobs_api, *args, **kwargs)
def export_backup(
api: ProjectsApi | TasksApi,
*,
max_retries: int = DEFAULT_RETRIES,
interval: float = DEFAULT_INTERVAL,
**kwargs,
) -> bytes | None:
endpoint = api.create_backup_export_endpoint
return export_v2(endpoint, max_retries=max_retries, interval=interval, **kwargs)
def export_project_backup(username: str, *args, **kwargs) -> bytes | None:
with make_api_client(username) as api_client:
return export_backup(api_client.projects_api, *args, **kwargs)
def export_task_backup(username: str, *args, **kwargs) -> bytes | None:
with make_api_client(username) as api_client:
return export_backup(api_client.tasks_api, *args, **kwargs)
def import_resource(
endpoint: Endpoint,
*,
max_retries: int = DEFAULT_RETRIES,
interval: float = DEFAULT_INTERVAL,
expect_forbidden: bool = False,
wait_result: bool = True,
**kwargs,
) -> models.Request | None:
# initialize background process and ensure that the first request returns 403 code if request should be forbidden
_, response = endpoint.call_with_http_info(
**kwargs,
_parse_response=False,
_check_status=False,
_content_type="multipart/form-data",
)
if expect_forbidden:
assert response.status == HTTPStatus.FORBIDDEN, "Request should be forbidden"
raise ForbiddenException()
assert response.status == HTTPStatus.ACCEPTED
if not wait_result:
return None
# define background request ID returned in the server response
rq_id = json.loads(response.data).get("rq_id")
assert rq_id, "The rq_id parameter was not found in the server response"
# check status of background process
for _ in range(max_retries):
background_request, response = endpoint.api_client.requests_api.retrieve(rq_id)
assert response.status == HTTPStatus.OK
if background_request.status.value in (
models.RequestStatus.allowed_values[("value",)]["FINISHED"],
models.RequestStatus.allowed_values[("value",)]["FAILED"],
):
break
sleep(interval)
else:
assert False, (
f"Import process was not finished within allowed time ({interval * max_retries}, sec). "
+ f"Last status was: {background_request.status.value}"
)
return background_request
def import_backup(
api: ProjectsApi | TasksApi,
*,
max_retries: int = DEFAULT_RETRIES,
interval: float = DEFAULT_INTERVAL,
**kwargs,
):
endpoint = api.create_backup_endpoint
return import_resource(endpoint, max_retries=max_retries, interval=interval, **kwargs)
def import_project_backup(username: str, file_content: BytesIO, **kwargs):
with make_api_client(username) as api_client:
return import_backup(
api_client.projects_api, project_file_request={"project_file": file_content}, **kwargs
)
def import_task_backup(username: str, file_content: BytesIO, **kwargs):
with make_api_client(username) as api_client:
return import_backup(
api_client.tasks_api, task_file_request={"task_file": file_content}, **kwargs
)
def import_project_dataset(username: str, file_content: BytesIO, **kwargs):
with make_api_client(username) as api_client:
return import_resource(
api_client.projects_api.create_dataset_endpoint,
dataset_file_request={"dataset_file": file_content},
**kwargs,
)
def import_task_annotations(username: str, file_content: BytesIO, **kwargs):
with make_api_client(username) as api_client:
return import_resource(
api_client.tasks_api.create_annotations_endpoint,
annotation_file_request={"annotation_file": file_content},
**kwargs,
)
def import_job_annotations(username: str, file_content: BytesIO, **kwargs):
with make_api_client(username) as api_client:
return import_resource(
api_client.jobs_api.create_annotations_endpoint,
annotation_file_request={"annotation_file": file_content},
**kwargs,
)
FieldPath: TypeAlias = Sequence[str | Callable]
class CollectionSimpleFilterTestBase(metaclass=ABCMeta):
# These fields need to be defined in the subclass
user: str
samples: list[dict[str, Any]]
field_lookups: dict[str, FieldPath] = None
cmp_ignore_keys: list[str] = ["updated_date"]
@abstractmethod
def _get_endpoint(self, api_client: ApiClient) -> Endpoint: ...
def _retrieve_collection(self, **kwargs) -> list:
kwargs["return_json"] = True
with make_api_client(self.user) as api_client:
return get_paginated_collection(self._get_endpoint(api_client), **kwargs)
@classmethod
def _get_field(cls, d: dict[str, Any], path: str | FieldPath) -> Any | None:
assert path
for key in path:
if isinstance(d, dict):
assert isinstance(key, str)
d = d.get(key)
else:
if callable(key):
assert isinstance(d, str)
d = key(d)
else:
d = None
return d
def _map_field(self, name: str) -> FieldPath:
return (self.field_lookups or {}).get(name, [name])
@classmethod
def _find_valid_field_value(
cls, samples: Iterator[dict[str, Any]], field_path: FieldPath
) -> Any:
value = None
for sample in samples:
value = cls._get_field(sample, field_path)
if value:
break
assert value, f"Failed to find a sample for the '{'.'.join(field_path)}' field"
return value
def _get_field_samples(self, field: str) -> tuple[Any, list[dict[str, Any]]]:
field_path = self._map_field(field)
field_value = self._find_valid_field_value(self.samples, field_path)
gt_objects = filter(lambda p: field_value == self._get_field(p, field_path), self.samples)
return field_value, gt_objects
def _compare_results(self, gt_objects, received_objects):
if self.cmp_ignore_keys:
ignore_keys = [f"root['{k}']" for k in self.cmp_ignore_keys]
else:
ignore_keys = None
diff = DeepDiff(
list(gt_objects),
received_objects,
exclude_paths=ignore_keys,
ignore_order=True,
)
assert diff == {}, diff
def _test_can_use_simple_filter_for_object_list(
self, field: str, field_values: list[Any] | None = None
):
gt_objects = []
field_path = self._map_field(field)
if not field_values:
value, gt_objects = self._get_field_samples(field)
field_values = [value]
are_gt_objects_initialized = bool(gt_objects)
for value in field_values:
if not are_gt_objects_initialized:
gt_objects = [
sample
for sample in self.samples
if value == self._get_field(sample, field_path)
]
received_items = self._retrieve_collection(**{field: value})
self._compare_results(gt_objects, received_items)
def get_attrs(obj: Any, attributes: Sequence[str]) -> tuple[Any, ...]:
"""Returns 1 or more object attributes as a tuple"""
return (getattr(obj, attr) for attr in attributes)
def build_exclude_paths_expr(ignore_fields: Iterator[str]) -> list[str]:
exclude_expr_parts = []
for key in ignore_fields:
if "." in key:
key_parts = key.split(".")
expr = r"root\['{}'\]".format(key_parts[0])
expr += "".join(r"\[.*\]\['{}'\]".format(part) for part in key_parts[1:])
else:
expr = r"root\['{}'\]".format(key)
exclude_expr_parts.append(expr)
return exclude_expr_parts
def wait_until_task_is_created(api: apis.RequestsApi, rq_id: str) -> models.Request:
for _ in range(100):
request_details, _ = api.retrieve(rq_id)
if request_details.status.value in ("finished", "failed"):
return request_details
sleep(1)
raise Exception("Cannot create task")
def create_task(username, spec, data, content_type="application/json", **kwargs):
with make_api_client(username) as api_client:
task, response_ = api_client.tasks_api.create(spec, **kwargs)
assert response_.status == HTTPStatus.CREATED
sent_upload_start = False
data_kwargs = (kwargs or {}).copy()
data_kwargs.pop("org", None)
data_kwargs.pop("org_id", None)
if data.get("client_files") and "json" in content_type:
_, response = api_client.tasks_api.create_data(
task.id,
data_request=models.DataRequest(image_quality=data["image_quality"]),
upload_start=True,
_content_type=content_type,
**data_kwargs,
)
assert response.status == HTTPStatus.ACCEPTED
sent_upload_start = True
# Can't encode binary files in json
_, response = api_client.tasks_api.create_data(
task.id,
data_request=models.DataRequest(
client_files=data["client_files"],
image_quality=data["image_quality"],
),
upload_multiple=True,
_content_type="multipart/form-data",
**data_kwargs,
)
assert response.status == HTTPStatus.OK
data = data.copy()
del data["client_files"]
last_kwargs = {}
if sent_upload_start:
last_kwargs["upload_finish"] = True
result, response = api_client.tasks_api.create_data(
task.id,
data_request=deepcopy(data),
_content_type=content_type,
**data_kwargs,
**last_kwargs,
)
assert response.status == HTTPStatus.ACCEPTED
request_details = wait_until_task_is_created(api_client.requests_api, result.rq_id)
assert request_details.status.value == "finished", request_details.message
return task.id, response_.headers.get("X-Request-Id")
def compare_annotations(
a: dict,
b: dict,
*,
ignore_spec_ids: bool = False,
ignore_source: bool = False,
ignore_score: bool = False,
) -> dict:
def _exclude_cb(obj, path: str):
# ignore track elements which do not have shapes
split_path = path.rsplit("['elements']", maxsplit=1)
if len(split_path) == 2:
if split_path[1].count("[") == 1 and not obj["shapes"]:
return True
return path.endswith("['elements']") and not obj
excluded_paths = [
r"root\['version|updated_date'\]",
r"root(\['\w+'\]\[\d+\])+\['id'\]",
]
if ignore_score:
excluded_paths += [
r"root(\['\w+'\]\[\d+\])+\['score'\]",
]
if ignore_source:
excluded_paths += [
r"root(\['\w+'\]\[\d+\])+\['source'\]",
]
if ignore_spec_ids:
excluded_paths += [
r"root(\['\w+'\]\[\d+\])+\['label_id'\]",
r"root(\['\w+'\]\[\d+\])+\['attributes'\]\[\d+\]\['spec_id'\]",
]
return DeepDiff(
a,
b,
ignore_order=True,
significant_digits=2, # annotations are stored with 2 decimal digit precision
exclude_obj_callback=_exclude_cb,
exclude_regex_paths=excluded_paths,
)
DATUMARO_FORMAT_FOR_DIMENSION = {
"2d": "Datumaro 1.0",
"3d": "Datumaro 3D 1.0",
}
def calc_end_frame(start_frame: int, stop_frame: int, frame_step: int) -> int:
return stop_frame - ((stop_frame - start_frame) % frame_step) + frame_step
_T = TypeVar("_T")
_T2 = TypeVar("_T2")
def unique(
it: Iterator[_T] | Iterable[_T], *, key: Callable[[_T], Hashable] = None
) -> Iterable[_T]:
return {key(v): v for v in it}.values()
def register_new_user(username: str) -> dict[str, Any]:
response = post_method(
"admin1",
"auth/register",
data={
"username": username,
"password1": USER_PASS,
"password2": USER_PASS,
"email": f"{username}@email.com",
},
)
assert response.status_code == HTTPStatus.CREATED
return response.json()
def invite_user_to_org(
user_email: str,
org_id: int,
role: str,
):
with make_api_client("admin1") as api_client:
invitation, _ = api_client.invitations_api.create(
models.InvitationWriteRequest(
role=role,
email=user_email,
),
org_id=org_id,
)
return invitation
def get_cloud_storage_content(
username: str,
cloud_storage_id: int,
*,
manifest: str | None = None,
prefix: str | None = None,
) -> list[str]:
kwargs = {}
if manifest is not None:
kwargs["manifest_path"] = manifest
if prefix is not None:
kwargs["prefix"] = prefix
prefix = (prefix or "").rstrip("/") + "/"
with make_api_client(username) as api_client:
data, _ = api_client.cloudstorages_api.retrieve_content_v2(cloud_storage_id, **kwargs)
return [
f"{prefix}{f['name']}{'/' if str(f['type']) == 'DIR' else ''}" for f in data["content"]
]
def export_events(
api_client: ApiClient,
*,
api_version: int,
max_retries: int = 100,
interval: float = 0.1,
**kwargs,
) -> bytes | None:
if api_version == 1:
endpoint = api_client.events_api.list_endpoint
query_id = ""
for _ in range(max_retries):
_, response = endpoint.call_with_http_info(
**kwargs, query_id=query_id, _parse_response=False
)
if response.status == HTTPStatus.CREATED:
break
assert response.status == HTTPStatus.ACCEPTED
if not query_id:
response_json = json.loads(response.data)
query_id = response_json["query_id"]
sleep(interval)
assert response.status == HTTPStatus.CREATED
_, response = endpoint.call_with_http_info(
**kwargs, query_id=query_id, action="download", _parse_response=False
)
assert response.status == HTTPStatus.OK
return response.data
assert api_version == 2
request_id, response = api_client.events_api.create_export(**kwargs, _check_status=False)
assert response.status == HTTPStatus.ACCEPTED
if "location" in kwargs and "cloud_storage_id" in kwargs:
background_request, response = wait_background_request(
api_client, rq_id=request_id.rq_id, max_retries=max_retries, interval=interval
)
assert background_request.result_url is None
return None
return wait_and_download_v2(
api_client, rq_id=request_id.rq_id, max_retries=max_retries, interval=interval
)
def iter_exclude(
it: Iterable[_T], excludes: Iterable[_T2], *, key: Callable[[_T], _T2] | None = None
) -> Iterable[_T]:
excludes = set(excludes)
if not key:
key = lambda v: v
return (v for v in it if key(v) not in excludes)
View File
+101
View File
@@ -0,0 +1,101 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import io
import zipfile
from pathlib import Path
from typing import TypeAlias
import pytest
from cvat_sdk.core.proxies.jobs import Job
from cvat_sdk.core.proxies.projects import Project
from cvat_sdk.core.proxies.tasks import Task
from cvat_sdk.core.proxies.types import Location
from shared.fixtures.data import CloudStorageAssets
from shared.utils.config import IMPORT_EXPORT_BUCKET_ID
from shared.utils.s3 import S3Client
from shared.utils.s3 import make_client as make_s3_client
from .util import make_pbar
ProjectOrTaskOrJob: TypeAlias = Project | Task | Job
class TestDatasetExport:
def _test_export_locally(
self,
resource: ProjectOrTaskOrJob,
*,
format_name: str,
file_path: Path,
**export_kwargs,
):
pbar_out = io.StringIO()
pbar = make_pbar(file=pbar_out)
resource.export_dataset(format_name, file_path, pbar=pbar, **export_kwargs)
assert self.stdout.getvalue() == ""
assert "100%" in pbar_out.getvalue().strip("\r").split("\r")[-1]
assert file_path.is_file()
def _test_export_to_cloud_storage(
self,
resource: ProjectOrTaskOrJob,
*,
format_name: str,
file_path: Path,
cs_client: S3Client,
**export_kwargs,
):
resource.export_dataset(format_name, file_path, **export_kwargs)
assert self.stdout.getvalue() == ""
dataset = cs_client.download_fileobj(str(file_path))
assert zipfile.is_zipfile(io.BytesIO(dataset))
def _test_can_export_dataset(
self,
resource: ProjectOrTaskOrJob,
*,
format_name: str,
file_path: Path,
include_images: bool,
location: Location | None,
request: pytest.FixtureRequest,
cloud_storages: CloudStorageAssets,
):
kwargs = {
"include_images": include_images,
"location": location,
}
expected_locally = (
location == Location.LOCAL
or not location
and (
not resource.target_storage
or resource.target_storage.location.value == Location.LOCAL
)
)
if expected_locally:
self._test_export_locally(
resource, format_name=format_name, file_path=file_path, **kwargs
)
else:
bucket = next(cs for cs in cloud_storages if cs["id"] == IMPORT_EXPORT_BUCKET_ID)[
"resource"
]
s3_client = make_s3_client(bucket=bucket)
request.addfinalizer(lambda: s3_client.remove_file(filename=str(file_path)))
self._test_export_to_cloud_storage(
resource,
format_name=format_name,
file_path=file_path,
cs_client=s3_client,
**(
{"cloud_storage_id": IMPORT_EXPORT_BUCKET_ID}
if location == Location.CLOUD_STORAGE
else {}
),
**kwargs,
)
+6
View File
@@ -0,0 +1,6 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
# Force execution of fixture definitions
from .fixtures import * # pylint: disable=wildcard-import
+210
View File
@@ -0,0 +1,210 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
from pathlib import Path
from typing import Callable
from zipfile import ZipFile
import pytest
from cvat_sdk import Client
from cvat_sdk.api_client import models
from cvat_sdk.core.proxies.types import Location
from PIL import Image
from rest_api.utils import register_new_user
from shared.utils.config import BASE_URL, IMPORT_EXPORT_BUCKET_ID, USER_PASS, make_sdk_client
from shared.utils.helpers import generate_image_file
from .util import OrgResourceHierarchy, generate_coco_json
@pytest.fixture
def fxt_client(fxt_logger):
logger, _ = fxt_logger
client = Client(BASE_URL, logger=logger)
api_client = client.api_client
for k in api_client.configuration.logger:
api_client.configuration.logger[k] = logger
client.config.status_check_period = 0.01
with client:
yield client
@pytest.fixture
def fxt_image_file(tmp_path: Path):
img_path = tmp_path / "img.png"
with img_path.open("wb") as f:
f.write(generate_image_file(filename=str(img_path), size=(5, 10)).getvalue())
return img_path
@pytest.fixture
def fxt_coco_file(tmp_path: Path, fxt_image_file: Path):
img_filename = fxt_image_file
img_size = Image.open(img_filename).size
ann_filename = tmp_path / "coco.json"
generate_coco_json(ann_filename, img_info=(img_filename, *img_size))
yield ann_filename
@pytest.fixture(scope="class")
def fxt_login(admin_user: str, restore_db_per_class):
client = Client(BASE_URL)
client.config.status_check_period = 0.01
user = admin_user
with client:
client.login((user, USER_PASS))
yield (client, user)
@pytest.fixture
def fxt_camvid_dataset(tmp_path: Path):
img_path = tmp_path / "img.png"
with img_path.open("wb") as f:
f.write(generate_image_file(filename=str(img_path), size=(5, 10)).getvalue())
annot_path = tmp_path / "annot.png"
r, g, b = (127, 0, 0)
annot = generate_image_file(
filename=str(annot_path),
size=(5, 10),
color=(r, g, b),
).getvalue()
with annot_path.open("wb") as f:
f.write(annot)
label_colors_path = tmp_path / "label_colors.txt"
with open(label_colors_path, "w") as f:
f.write(f"{r} {g} {b} car\n")
dataset_img_path = "default/img.png"
dataset_annot_path = "default/annot.png"
default_txt_path = tmp_path / "default.txt"
with open(default_txt_path, "w") as f:
f.write(f"/{dataset_img_path} {dataset_annot_path}")
dataset_path = tmp_path / "camvid_dataset.zip"
with ZipFile(dataset_path, "x") as f:
f.write(img_path, arcname=dataset_img_path)
f.write(annot_path, arcname=dataset_annot_path)
f.write(default_txt_path, arcname="default.txt")
f.write(label_colors_path, arcname="label_colors.txt")
yield dataset_path
@pytest.fixture
def fxt_coco_dataset(tmp_path: Path, fxt_image_file: Path, fxt_coco_file: Path):
dataset_path = tmp_path / "coco_dataset.zip"
with ZipFile(dataset_path, "x") as f:
f.write(fxt_image_file, arcname="images/" + fxt_image_file.name)
f.write(fxt_coco_file, arcname="annotations/instances_default.json")
yield dataset_path
@pytest.fixture
def fxt_new_task(fxt_image_file: Path, fxt_login: tuple[Client, str]):
client, _ = fxt_login
task = client.tasks.create_from_data(
spec={
"name": "test_task",
"labels": [{"name": "car"}, {"name": "person"}],
},
resources=[fxt_image_file],
data_params={"image_quality": 80},
)
yield task
@pytest.fixture
def fxt_new_task_with_target_storage(fxt_image_file: Path, fxt_login: tuple[Client, str]):
client, _ = fxt_login
task = client.tasks.create_from_data(
spec={
"name": "test_task",
"labels": [{"name": "car"}, {"name": "person"}],
"target_storage": {
"location": Location.CLOUD_STORAGE,
"cloud_storage_id": IMPORT_EXPORT_BUCKET_ID,
},
},
resources=[fxt_image_file],
data_params={"image_quality": 80},
)
yield task
@pytest.fixture
def fxt_org_resource_hierarchy(
fxt_image_file: Path,
) -> Callable[..., OrgResourceHierarchy]:
def create(
*, include_issue: bool = False, include_comment: bool = False
) -> OrgResourceHierarchy:
owner = register_new_user("sdkowner")
maintainer = register_new_user("sdkmaintainer")
with make_sdk_client(owner["username"]) as owner_client:
org = owner_client.organizations.create(models.OrganizationWriteRequest(slug="sdkorg"))
owner_client.organization_slug = org.slug
owner_client.api_client.invitations_api.create(
models.InvitationWriteRequest(
role="maintainer",
email=maintainer["email"],
),
)
project = owner_client.projects.create(
spec=models.ProjectWriteRequest(
name="org project",
labels=[models.PatchedLabelRequest(name="car")],
)
)
task = owner_client.tasks.create_from_data(
spec=models.TaskWriteRequest(name="org task", project_id=project.id),
resources=[fxt_image_file],
data_params={"image_quality": 80},
)
job = task.get_jobs()[0]
issue_id = None
comment_id = None
if include_issue or include_comment:
issue = owner_client.issues.create(
models.IssueWriteRequest(
frame=0,
position=[2.0, 4.0],
job=job.id,
message="hello",
)
)
issue_id = issue.id
if include_comment:
comment = owner_client.comments.create(
models.CommentWriteRequest(issue.id, message="hi!")
)
comment_id = comment.id
return OrgResourceHierarchy(
owner_username=owner["username"],
maintainer_username=maintainer["username"],
org_slug=org.slug,
project_id=project.id,
task_id=task.id,
job_id=job.id,
issue_id=issue_id,
comment_id=comment_id,
)
return create
+9
View File
@@ -0,0 +1,9 @@
-----BEGIN CERTIFICATE-----
MIIBPDCB76ADAgECAhQksQwFGcyVwF0+gIOPMPBB+/NjNTAFBgMrZXAwFDESMBAG
A1UEAwwJbG9jYWxob3N0MB4XDTI0MTAyODEyMTkyNFoXDTI0MTAyOTEyMTkyNFow
FDESMBAGA1UEAwwJbG9jYWxob3N0MCowBQYDK2VwAyEAzGOv96vkrHr0GPcWL7vN
8mgR4XMg9ItNpJ2nbMmjYCKjUzBRMB0GA1UdDgQWBBR6Hn0aG/ZGAJjY9HIUK7El
84qAgzAfBgNVHSMEGDAWgBR6Hn0aG/ZGAJjY9HIUK7El84qAgzAPBgNVHRMBAf8E
BTADAQH/MAUGAytlcANBAMj2zWdIa8oOiEtUWFMv+KYf1kyP1lUnlcC2xUpOj8d3
kRYtlRX4E7F5zzzgKgNpbanRAg72qnqPiFAFCGVAhgY=
-----END CERTIFICATE-----
+3
View File
@@ -0,0 +1,3 @@
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIKe5zj/UrVJ/LySjKm9BBVHXziqFIwJ6w+HuTHnldCLo
-----END PRIVATE KEY-----
@@ -0,0 +1,59 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
from pathlib import Path
import pytest
from cvat_sdk.api_client import models
from cvat_sdk.core.proxies.tasks import Task
@pytest.mark.parametrize("target_type", ["task", "job"])
@pytest.mark.parametrize(
("import_mode", "expected_shape_count"),
[
("replace", 1),
("append", 2),
],
)
def test_import_annotations_respects_import_mode(
fxt_new_task: Task,
target_type: str,
import_mode: str,
expected_shape_count: int,
tmp_path: Path,
):
labels = fxt_new_task.get_labels()
target = fxt_new_task if target_type == "task" else fxt_new_task.get_jobs()[0]
target.set_annotations(
models.LabeledDataRequest(
shapes=[
models.LabeledShapeRequest(
frame=0,
label_id=labels[0].id,
type="rectangle",
points=[1, 1, 2, 2],
),
],
)
)
original_annotations = target.get_annotations()
assert len(original_annotations.shapes) == 1
exported_dataset = target.export_dataset(
format_name="CVAT for images 1.1",
filename=tmp_path / f"{target_type}_annotations.zip",
include_images=False,
)
target.import_annotations(
format_name="CVAT 1.1",
filename=exported_dataset,
import_mode=import_mode,
)
imported_annotations = target.get_annotations()
assert len(imported_annotations.shapes) == expected_shape_count
+94
View File
@@ -0,0 +1,94 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import json
from types import SimpleNamespace
from cvat_sdk.api_client import ApiClient, Configuration
from cvat_sdk.api_client.api.tasks_api import TasksApi
from shared.utils.config import BASE_URL, make_api_client
def test_can_make_custom_request_with_call_api_method(admin_user):
with make_api_client(admin_user) as api_client:
_, response = api_client.call_api("/api/users/self", method="GET", _parse_response=False)
assert json.loads(response.data)["username"] == admin_user
def test_can_make_custom_request_with_request_method(admin_user):
with make_api_client(admin_user) as api_client:
headers = api_client.get_common_headers()
query_params = []
api_client.update_params_for_auth(headers=headers, queries=query_params)
assert not query_params
response = api_client.request(
"GET", BASE_URL + "/api/users/self", headers=headers, _parse_response=False
)
assert json.loads(response.data)["username"] == admin_user
def test_call_api_request_headers_override_defaults_and_skip_none(monkeypatch):
api_client = ApiClient(Configuration(host=BASE_URL))
api_client.set_default_header("X-Organization", "default-org")
api_client.set_default_header("X-Remove-Me", "default-value")
captured_headers = {}
def fake_request(method, url, query_params=None, headers=None, **kwargs):
captured_headers.update(headers)
return SimpleNamespace(data=b"{}", status=200, msg="OK", headers={})
monkeypatch.setattr(api_client, "request", fake_request)
_, response = api_client.call_api(
"/api/server/about",
method="GET",
header_params={
"X-Organization": "request-org",
"X-Remove-Me": None,
},
_parse_response=False,
)
assert response.status == 200
assert captured_headers["X-Organization"] == "request-org"
assert "X-Remove-Me" not in captured_headers
def test_endpoint_header_params_with_none_are_omitted(monkeypatch):
api_client = ApiClient(Configuration(host=BASE_URL))
api_client.set_default_header("X-Organization", "default-org")
captured_request = {}
def fake_request(method, url, query_params=None, headers=None, **kwargs):
captured_request.update(
method=method,
url=url,
query_params=query_params,
headers=headers,
)
return SimpleNamespace(
data=b'{"count":0,"next":null,"previous":null,"results":[]}',
status=200,
msg="OK",
headers={},
)
monkeypatch.setattr(api_client, "request", fake_request)
_, response = TasksApi(api_client).list_endpoint.call_with_http_info(
org_id=123,
x_organization=None,
_parse_response=False,
)
assert response.status == 200
assert "X-Organization" not in captured_request["headers"]
assert dict(captured_request["query_params"]) == {"org_id": 123}
+124
View File
@@ -0,0 +1,124 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import pickle
from copy import deepcopy
from cvat_sdk import models
from deepdiff import DeepDiff
def test_models_do_not_change_input_values():
# Nested containers may be modified during the model input data parsing.
# This can lead to subtle memory errors, which are very hard to find.
original_input_data = {
"name": "test",
"labels": [
{
"name": "cat",
"attributes": [
{
"default_value": "yy",
"input_type": "text",
"mutable": False,
"name": "x",
"values": ["yy"],
},
{
"default_value": "1",
"input_type": "radio",
"mutable": False,
"name": "y",
"values": ["1", "2"],
},
],
}
],
}
input_data = deepcopy(original_input_data)
models.TaskWriteRequest(**input_data)
assert DeepDiff(original_input_data, input_data) == {}
def test_models_do_not_store_input_collections():
# Avoid depending on input data for collection fields after the model is initialized.
# This can lead to subtle memory errors and unexpected behavior
# if the original input data is modified.
input_data = {
"name": "test",
"labels": [
{
"name": "cat1",
"attributes": [
{
"default_value": "yy",
"input_type": "text",
"mutable": False,
"name": "x",
"values": ["yy"],
},
{
"default_value": "1",
"input_type": "radio",
"mutable": False,
"name": "y",
"values": ["1", "2"],
},
],
},
{"name": "cat2", "attributes": []},
],
}
model = models.TaskWriteRequest(**input_data)
model_data1 = model.to_dict()
# Modify input value containers
input_data["labels"][0]["attributes"].clear()
input_data["labels"][1]["attributes"].append(
{
"default_value": "",
"input_type": "text",
"mutable": True,
"name": "z",
}
)
input_data["labels"].append({"name": "dog"})
model_data2 = model.to_dict()
assert DeepDiff(model_data1, model_data2) == {}
def test_models_do_not_return_internal_collections():
# Avoid returning internal data for mutable collection fields.
# This can lead to subtle memory errors and unexpected behavior
# if the returned data is modified.
input_data = {
"name": "test",
"labels": [],
}
model = models.TaskWriteRequest(**input_data)
model_data1 = model.to_dict()
model_data1_original = deepcopy(model_data1)
# Modify an output value container
model_data1["labels"].append({"name": "dog"})
model_data2 = model.to_dict()
assert DeepDiff(model_data1_original, model_data2) == {}
def test_models_are_pickleable():
model = models.PatchedLabelRequest(id=5, name="person")
pickled_model = pickle.dumps(model)
unpickled_model = pickle.loads(pickled_model)
assert unpickled_model.id == model.id
assert unpickled_model.name == model.name
+120
View File
@@ -0,0 +1,120 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import pytest
from cvat_sdk import models
from cvat_sdk.attributes import (
attribute_vals_from_dict,
attribute_value_validator,
number_attribute_values,
)
def test_number_attribute_values_can_convert_good_values():
assert number_attribute_values(0, 0, 1) == ["0", "0", "1"]
assert number_attribute_values(0, 10, 1) == ["0", "10", "1"]
assert number_attribute_values(0, 10, 10) == ["0", "10", "10"]
assert number_attribute_values(0, 10, 5) == ["0", "10", "5"]
def test_number_attribute_values_can_reject_bad_values():
with pytest.raises(ValueError, match="min_value must be less than or equal to max_value"):
number_attribute_values(1, 0, 1)
with pytest.raises(ValueError, match="step must be positive"):
number_attribute_values(0, 10, 0)
with pytest.raises(ValueError, match="step must be positive"):
number_attribute_values(0, 10, -1)
with pytest.raises(ValueError, match="step must be a divisor of max_value - min_value"):
number_attribute_values(0, 10, 3)
def test_attribute_value_validator_checkbox():
validator = attribute_value_validator(
models.AttributeRequest(name="a", mutable=False, input_type="checkbox", values=[])
)
assert validator("true")
assert validator("false")
assert not validator("maybe")
def test_attribute_value_validator_number():
validator = attribute_value_validator(
models.AttributeRequest(
name="a", mutable=False, input_type="number", values=["0", "10", "2"]
)
)
assert validator("0")
assert validator("2")
assert validator("10")
assert not validator("1")
assert not validator("-2")
assert not validator("12")
assert not validator("not a number")
@pytest.mark.parametrize(
["values", "exc_match"],
[
(["0", "1"], "wrong number of values"),
(["0", "10", "1", "1"], "wrong number of values"),
(["a", "10", "1"], "values could not be converted to integers"),
(["0", "a", "1"], "values could not be converted to integers"),
(["0", "10", "a"], "values could not be converted to integers"),
(["0", "10", "0"], "step must be positive"),
(["1", "0", "1"], "min_value must be less than or equal to max_value"),
(["0", "10", "3"], "step must be a divisor of max_value - min_value"),
],
)
def test_attribute_value_validator_number_bad_spec(values, exc_match):
with pytest.raises(ValueError, match=exc_match):
attribute_value_validator(
models.AttributeRequest(name="a", mutable=False, input_type="number", values=values)
)
@pytest.mark.parametrize("input_type", ["radio", "select"])
def test_attribute_value_validator_radiolike(input_type: str):
validator = attribute_value_validator(
models.AttributeRequest(name="a", mutable=False, input_type=input_type, values=["a", "b"])
)
assert validator("a")
assert validator("b")
assert not validator("c")
@pytest.mark.parametrize("input_type", ["radio", "select"])
def test_attribute_value_validator_radiolike_bad_spec(input_type: str):
with pytest.raises(ValueError, match="empty list of allowed values"):
attribute_value_validator(
models.AttributeRequest(name="a", mutable=False, input_type=input_type, values=[])
)
def test_attribute_value_validator_text():
validator = attribute_value_validator(
models.AttributeRequest(name="a", mutable=False, input_type="text", values=[])
)
assert validator("anything")
def test_attribute_vals_from_dict():
assert attribute_vals_from_dict({}) == []
attrs = attribute_vals_from_dict({0: "x", 1: 5, 2: True, 3: False})
assert len(attrs) == 4
for i, attr in enumerate(attrs):
assert attr.spec_id == i
assert attrs[0].value == "x"
assert attrs[1].value == "5"
assert attrs[2].value == "true"
assert attrs[3].value == "false"
+272
View File
@@ -0,0 +1,272 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import argparse
import logging
import pytest
from cvat_sdk.core.auth import (
CVAT_ACCESS_TOKEN_ENV_VAR,
DEFAULT_SERVER,
AuthStore,
AuthStoreError,
ClientAuthParameters,
ProfileEntry,
configure_client_auth_arguments,
default_auth_factory,
get_auth_factory,
make_client_from_cli,
make_client_from_profile,
)
from cvat_sdk.core.client import AccessTokenCredentials, Config, PasswordCredentials
AUTH_URL = "https://auth.test.invalid"
PROFILE_URL = "https://profile.test.invalid"
EXPLICIT_URL = "https://explicit.test.invalid"
STUB_URL = "https://stub.test.invalid"
class _FakeClient:
def __init__(self, url, **kwargs):
self.url = url
self.logged_in_with = None
self.organization_slug = None
self.config = kwargs.get("config")
self.logger = kwargs.get("logger")
self.check_server_version = kwargs.get("check_server_version")
self.api_client = type(
"ApiClient",
(),
{"configuration": type("Config", (), {"host": STUB_URL})()},
)()
def login(self, credentials):
self.logged_in_with = credentials
def _ns(**kw):
ns = argparse.Namespace(
profile=None,
auth=None,
server_host=None,
server_port=None,
insecure=False,
organization=None,
)
for k, v in kw.items():
setattr(ns, k, v)
return ns
def test_get_auth_factory_parses_user_pass():
cred = get_auth_factory("alice:secret")(AUTH_URL)
assert isinstance(cred, PasswordCredentials)
assert cred.user == "alice"
assert cred.password == "secret"
def test_default_auth_factory_reads_env_token(monkeypatch):
monkeypatch.setenv(CVAT_ACCESS_TOKEN_ENV_VAR, "pat-123")
cred = default_auth_factory()(AUTH_URL)
assert isinstance(cred, AccessTokenCredentials)
assert cred.token == "pat-123"
def test_configure_client_auth_arguments_defines_shared_parser_surface():
parser = argparse.ArgumentParser()
configure_client_auth_arguments(parser)
args = parser.parse_args(
[
"--profile",
"p",
"--insecure",
"--organization",
"org",
]
)
params = ClientAuthParameters.from_namespace(args)
assert params.profile == "p"
assert params.insecure is True
assert params.organization == "org"
def test_configure_client_auth_arguments_parses_auth_and_host():
parser = argparse.ArgumentParser()
configure_client_auth_arguments(parser)
args = parser.parse_args(
[
"--server-host",
EXPLICIT_URL,
"--server-port",
"8443",
"--auth",
"alice:secret",
]
)
params = ClientAuthParameters.from_namespace(args)
cred = params.auth(AUTH_URL)
assert params.server_host == EXPLICIT_URL
assert params.server_port == 8443
assert isinstance(cred, PasswordCredentials)
assert cred.user == "alice"
assert cred.password == "secret"
def test_make_client_from_profile_uses_profile_entry(monkeypatch):
from cvat_sdk.core import auth as auth_mod
logger = logging.getLogger("test-auth")
config = Config(status_check_period=0.1, verify_ssl=False)
entry = ProfileEntry(
server=PROFILE_URL, token="pat-9", created_date="2026-01-01T00:00:00+00:00"
)
monkeypatch.setattr(auth_mod, "Client", _FakeClient)
client = make_client_from_profile(
entry, logger=logger, config=config, check_server_version=True
)
assert client.url == PROFILE_URL
assert client.logged_in_with.token == "pat-9"
assert client.logger is logger
assert client.config is config
assert client.check_server_version is True
def test_make_client_from_cli_unknown_profile_raises(tmp_path):
store = AuthStore(path=tmp_path / "auth.json")
with pytest.raises(AuthStoreError, match="profile"):
make_client_from_cli(_ns(profile="ghost"), store=store)
def test_make_client_from_cli_uses_existing_named_profile(tmp_path, monkeypatch):
from cvat_sdk.core import auth as auth_mod
store = AuthStore(path=tmp_path / "auth.json")
store.put_profile(
"p",
ProfileEntry(server=PROFILE_URL, token="pat", created_date="2026-01-01T00:00:00+00:00"),
)
monkeypatch.setattr(auth_mod, "Client", _FakeClient)
client = make_client_from_cli(_ns(profile="p"), store=store)
assert client.url == PROFILE_URL
assert client.logged_in_with.token == "pat"
def test_make_client_from_cli_profile_conflicts_with_explicit_host_or_auth(tmp_path):
store = AuthStore(path=tmp_path / "auth.json")
with pytest.raises(AuthStoreError, match="mutually exclusive"):
make_client_from_cli(_ns(profile="p", server_host=EXPLICIT_URL), store=store)
with pytest.raises(AuthStoreError, match="mutually exclusive"):
make_client_from_cli(_ns(profile="p", auth=get_auth_factory("u:p")), store=store)
def test_make_client_from_cli_zero_flag_uses_default_profile(tmp_path, monkeypatch):
from cvat_sdk.core import auth as auth_mod
store = AuthStore(path=tmp_path / "auth.json")
store.put_profile(
"p",
ProfileEntry(server=PROFILE_URL, token="dpat", created_date="2026-01-01T00:00:00+00:00"),
set_default=True,
)
monkeypatch.setattr(auth_mod, "Client", _FakeClient)
monkeypatch.delenv(CVAT_ACCESS_TOKEN_ENV_VAR, raising=False)
client = make_client_from_cli(_ns(), store=store)
assert client.url == PROFILE_URL
assert client.logged_in_with.token == "dpat"
def test_make_client_from_cli_applies_client_options_to_named_profile(tmp_path, monkeypatch):
from cvat_sdk.core import auth as auth_mod
logger = logging.getLogger("test-auth")
config = Config(status_check_period=0.1)
store = AuthStore(path=tmp_path / "auth.json")
store.put_profile(
"p",
ProfileEntry(server=PROFILE_URL, token="pat", created_date="2026-01-01T00:00:00+00:00"),
)
monkeypatch.setattr(auth_mod, "Client", _FakeClient)
client = make_client_from_cli(
_ns(profile="p", insecure=True),
logger=logger,
config=config,
check_server_version=True,
store=store,
)
assert client.url == PROFILE_URL
assert client.config.status_check_period == 0.1
assert client.config.verify_ssl is False
assert client.logger is logger
assert client.check_server_version is True
def test_make_client_from_cli_explicit_host_does_not_borrow_profile_token(tmp_path, monkeypatch):
from cvat_sdk.core import auth as auth_mod
store = AuthStore(path=tmp_path / "auth.json")
store.put_profile(
"p",
ProfileEntry(server=PROFILE_URL, token="dpat", created_date="2026-01-01T00:00:00+00:00"),
set_default=True,
)
monkeypatch.setattr(auth_mod, "Client", _FakeClient)
monkeypatch.setenv(CVAT_ACCESS_TOKEN_ENV_VAR, "env-tok")
client = make_client_from_cli(_ns(server_host=EXPLICIT_URL), store=store)
assert client.url == EXPLICIT_URL
assert client.logged_in_with.token == "env-tok"
def test_make_client_from_cli_accepts_typed_parameters_and_config(tmp_path, monkeypatch):
from cvat_sdk.core import auth as auth_mod
config = Config(status_check_period=0.1)
store = AuthStore(path=tmp_path / "auth.json")
monkeypatch.setattr(auth_mod, "Client", _FakeClient)
monkeypatch.setenv(CVAT_ACCESS_TOKEN_ENV_VAR, "env-tok")
client = make_client_from_cli(
ClientAuthParameters(server_host=EXPLICIT_URL, insecure=True),
config=config,
store=store,
)
assert client.url == EXPLICIT_URL
assert client.config.status_check_period == 0.1
assert client.config.verify_ssl is False
assert client.logged_in_with.token == "env-tok"
def test_make_client_from_cli_env_token_zero_flag(tmp_path, monkeypatch):
from cvat_sdk.core import auth as auth_mod
store = AuthStore(path=tmp_path / "auth.json")
monkeypatch.setattr(auth_mod, "Client", _FakeClient)
monkeypatch.setenv(CVAT_ACCESS_TOKEN_ENV_VAR, "env-tok")
client = make_client_from_cli(_ns(), store=store)
assert client.url == DEFAULT_SERVER
assert client.logged_in_with.token == "env-tok"
def test_public_exports():
import cvat_sdk
import cvat_sdk.core
for name in (
"AuthStore",
"ClientAuthParameters",
"ProfileEntry",
"configure_client_auth_arguments",
"get_auth_store_path",
"make_client_from_cli",
"make_client_from_profile",
):
assert hasattr(cvat_sdk, name), f"cvat_sdk.{name} missing"
assert hasattr(cvat_sdk.core, name), f"cvat_sdk.core.{name} missing"
+224
View File
@@ -0,0 +1,224 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import json
import os
from pathlib import Path
import platformdirs
import pytest
from cvat_sdk.core.auth import (
AuthStore,
AuthStoreError,
ProfileEntry,
get_auth_store_path,
)
from cvat_sdk.core.utils import is_posix
def test_auth_store_path_matches_platformdirs():
expected = platformdirs.user_config_path("cvat-sdk", "CVAT.ai") / "auth.json"
assert get_auth_store_path() == expected
assert isinstance(get_auth_store_path(), Path)
def _store(tmp_path) -> AuthStore:
return AuthStore(path=tmp_path / "cvat" / "auth.json")
def test_load_returns_empty_doc_when_file_absent(tmp_path):
doc = _store(tmp_path)._load()
assert doc == {"version": 1, "profiles": {}}
def test_save_then_load_roundtrips(tmp_path):
store = _store(tmp_path)
store._save({"version": 1, "profiles": {"a": {"server": "https://x", "token": "t"}}})
assert store._load()["profiles"]["a"]["token"] == "t"
@pytest.mark.skipif(not is_posix(), reason="POSIX permission semantics")
def test_save_creates_0600_file_in_0700_dir(tmp_path):
store = _store(tmp_path)
store._save({"version": 1, "profiles": {}})
path = tmp_path / "cvat" / "auth.json"
assert (path.stat().st_mode & 0o777) == 0o600
assert (path.parent.stat().st_mode & 0o777) == 0o700
@pytest.mark.skipif(not is_posix(), reason="POSIX permission semantics")
def test_load_refuses_world_readable_file(tmp_path):
store = _store(tmp_path)
store._save({"version": 1, "profiles": {}})
path = tmp_path / "cvat" / "auth.json"
os.chmod(path, 0o644)
with pytest.raises(AuthStoreError, match="permission"):
AuthStore(path=path)._load()
@pytest.mark.skipif(not is_posix(), reason="POSIX permission semantics")
def test_load_allows_file_with_secure_base_permissions_and_special_bits(tmp_path):
store = _store(tmp_path)
store._save({"version": 1, "profiles": {}})
path = tmp_path / "cvat" / "auth.json"
# 0o1600 is 0o600 plus the POSIX sticky bit; only the base permission bits matter.
os.chmod(path, 0o1600)
assert AuthStore(path=path)._load() == {"version": 1, "profiles": {}}
def test_load_rejects_directory_path(tmp_path):
path = tmp_path / "cvat" / "auth.json"
path.mkdir(parents=True)
if is_posix():
os.chmod(path.parent, 0o700)
os.chmod(path, 0o700)
with pytest.raises(AuthStoreError, match="must be a file"):
_store(tmp_path)._load()
def test_load_rejects_unknown_version(tmp_path):
path = tmp_path / "cvat" / "auth.json"
path.parent.mkdir(parents=True)
path.write_text(json.dumps({"version": 999, "profiles": {}}))
if is_posix():
os.chmod(path.parent, 0o700)
os.chmod(path, 0o600)
with pytest.raises(AuthStoreError, match="version"):
_store(tmp_path)._load()
def test_load_rejects_corrupt_json(tmp_path):
path = tmp_path / "cvat" / "auth.json"
path.parent.mkdir(parents=True)
path.write_text("{not json")
if is_posix():
os.chmod(path.parent, 0o700)
os.chmod(path, 0o600)
with pytest.raises(AuthStoreError):
_store(tmp_path)._load()
def _entry(server="https://app.cvat.ai", token="tok") -> ProfileEntry:
return ProfileEntry(server=server, token=token, created_date="2026-01-01T00:00:00+00:00")
def test_put_get_list_remove_profile(tmp_path):
store = _store(tmp_path)
assert store.list_profiles() == {}
store.put_profile("mycvat", _entry())
assert store.get_profile("mycvat") == _entry()
assert set(store.list_profiles()) == {"mycvat"}
store.remove_profile("mycvat")
assert store.get_profile("mycvat") is None
def test_failed_write_does_not_pollute_cached_doc(tmp_path, monkeypatch):
store = _store(tmp_path)
store.put_profile("kept", _entry())
def fail_save(_doc):
raise AuthStoreError("save failed")
monkeypatch.setattr(store, "_save", fail_save)
with pytest.raises(AuthStoreError, match="save failed"):
store.put_profile("ghost", _entry(token="ghost"))
assert set(store.list_profiles()) == {"kept"}
assert store.get_profile("ghost") is None
def test_auth_store_reuses_loaded_doc(tmp_path, monkeypatch):
store = _store(tmp_path)
store._save(
{
"version": 1,
"profiles": {
"mycvat": {
"server": "https://x",
"token": "t",
"created_date": "2026-01-01T00:00:00+00:00",
}
},
}
)
store = _store(tmp_path)
read_count = 0
original_read_text = Path.read_text
def read_text(self, *args, **kwargs):
nonlocal read_count
read_count += 1
return original_read_text(self, *args, **kwargs)
monkeypatch.setattr(Path, "read_text", read_text)
assert store.get_profile("mycvat") is not None
assert set(store.list_profiles()) == {"mycvat"}
assert read_count == 1
def test_first_profile_becomes_default_when_requested(tmp_path):
store = _store(tmp_path)
store.put_profile("mycvat", _entry(), set_default=True)
name, entry = store.get_default_profile()
assert name == "mycvat"
assert entry == _entry()
def test_first_profile_becomes_default_even_without_flag(tmp_path):
store = _store(tmp_path)
store.put_profile("mycvat", _entry())
assert store.get_default_profile()[0] == "mycvat"
def test_put_profile_after_clear_default_does_not_recreate_default(tmp_path):
store = _store(tmp_path)
store.put_profile("first", _entry())
store.clear_default_profile()
store.put_profile("second", _entry(token="second"))
assert store.get_default_profile() is None
def test_set_default_profile_requires_existing(tmp_path):
store = _store(tmp_path)
with pytest.raises(KeyError):
store.set_default_profile("nope")
def test_removing_default_profile_clears_default(tmp_path):
store = _store(tmp_path)
store.put_profile("mycvat", _entry(), set_default=True)
store.remove_profile("mycvat")
assert store.get_default_profile() is None
def test_put_profile_after_removing_default_with_profiles_remaining_does_not_recreate_default(
tmp_path,
):
store = _store(tmp_path)
store.put_profile("first", _entry())
store.put_profile("second", _entry(token="second"))
store.remove_profile("first")
store.put_profile("third", _entry(token="third"))
assert store.get_default_profile() is None
def test_default_server_set_get_clear(tmp_path):
store = _store(tmp_path)
assert store.get_default_server() is None
store.set_default_server("https://staging.example.com")
assert store.get_default_server() == "https://staging.example.com"
store.clear_default_server()
assert store.get_default_server() is None
def test_remove_unknown_profile_raises(tmp_path):
with pytest.raises(KeyError):
_store(tmp_path).remove_profile("ghost")
File diff suppressed because it is too large Load Diff
+325
View File
@@ -0,0 +1,325 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import io
from contextlib import ExitStack
from logging import Logger
import packaging.version as pv
import pytest
from cvat_sdk import Client, models
from cvat_sdk.core.client import AccessTokenCredentials, Config, PasswordCredentials, make_client
from cvat_sdk.core.exceptions import IncompatibleVersionException, InvalidHostException
from cvat_sdk.exceptions import ApiException
from shared.utils.config import BASE_URL, USER_PASS
from .util import https_reverse_proxy
class TestClientUsecases:
@pytest.fixture(autouse=True)
def setup(
self,
restore_db_per_function, # force fixture call order to allow DB setup
fxt_logger: tuple[Logger, io.StringIO],
fxt_client: Client,
fxt_stdout: io.StringIO,
admin_user: str,
):
_, self.logger_stream = fxt_logger
self.client = fxt_client
self.stdout = fxt_stdout
self.user = admin_user
yield
def test_can_login_with_basic_auth(self):
self.client.login(PasswordCredentials(self.user, USER_PASS))
assert self.client.users.retrieve_current_user().username == self.user
assert self.client.has_credentials()
def test_can_fail_to_login_with_basic_auth(self):
with pytest.raises(ApiException):
self.client.login((self.user, USER_PASS + "123"))
def test_can_logout_after_basic_auth_login(self):
self.client.login((self.user, USER_PASS))
self.client.logout()
assert not self.client.has_credentials()
def test_can_login_with_pat_auth(self, access_tokens_by_username):
user, token = next((u, t) for u, ts in access_tokens_by_username.items() for t in ts)
self.client.login(AccessTokenCredentials(token["private_key"]))
assert self.client.users.retrieve_current_user().username == user
assert self.client.has_credentials()
def test_can_logout_after_pat_login(self, access_tokens):
token = next(t for t in access_tokens)
self.client.login(AccessTokenCredentials(token["private_key"]))
self.client.logout()
assert not self.client.has_credentials()
def test_can_get_server_version(self):
self.client.login((self.user, USER_PASS))
version = self.client.get_server_version()
assert (version.major, version.minor) >= (2, 0)
class TestClientFactory:
def test_can_make_client_with_pat_auth(self, access_tokens_by_username):
user, token = next((u, t) for u, ts in access_tokens_by_username.items() for t in ts)
host, port = BASE_URL.rsplit(":", maxsplit=1)
with make_client(host=host, port=port, access_token=token["private_key"]) as client:
assert client.users.retrieve_current_user().username == user
def test_can_strip_trailing_slash_in_hostname(self, admin_user: str):
host, port = BASE_URL.rsplit(":", maxsplit=1)
with make_client(host=host + "/", port=port, credentials=(admin_user, USER_PASS)) as client:
assert client.api_map.host == BASE_URL
def test_can_strip_trailing_slash_in_hostname_in_client_ctor(self, admin_user: str):
with Client(url=BASE_URL + "/") as client:
client.login((admin_user, USER_PASS))
assert client.api_map.host == BASE_URL
def test_can_add_default_server_schema(self):
with https_reverse_proxy() as proxy_url:
with Client(
url=proxy_url.removeprefix("https://"), config=Config(verify_ssl=False)
) as client:
assert client.api_map.host == proxy_url
def test_can_reject_invalid_server_schema(self):
host, port = BASE_URL.split("://", maxsplit=1)[1].rsplit(":", maxsplit=1)
with pytest.raises(InvalidHostException) as capture:
make_client(host="ftp://" + host, port=int(port) + 1)
assert capture.match(r"Invalid url schema 'ftp'")
def test_can_use_server_url(self, admin_user):
with make_client(BASE_URL, credentials=(admin_user, USER_PASS)):
pass
def test_cannot_use_server_url_with_port_and_port_parameter(self):
with pytest.raises(ValueError, match="Please specify only one port"):
make_client(BASE_URL, port=1)
def test_cannot_use_both_credentials_and_access_token(self):
with pytest.raises(ValueError, match="'credentials' and 'access_token' cannot"):
make_client(BASE_URL, credentials=("name", "pass"), access_token="token")
@pytest.mark.parametrize("raise_exception", (True, False))
def test_can_warn_on_mismatching_server_version(
fxt_logger: tuple[Logger, io.StringIO], monkeypatch, raise_exception: bool
):
logger, logger_stream = fxt_logger
def mocked_version(_):
return pv.Version("0")
monkeypatch.setattr(Client, "get_server_version", mocked_version)
config = Config()
with ExitStack() as es:
if raise_exception:
config.allow_unsupported_server = False
es.enter_context(pytest.raises(IncompatibleVersionException))
Client(url=BASE_URL, logger=logger, config=config)
assert "Server version '0' is not compatible with SDK version" in logger_stream.getvalue()
@pytest.mark.parametrize("do_check", (True, False))
def test_can_check_server_version_in_ctor(
fxt_logger: tuple[Logger, io.StringIO], monkeypatch, do_check: bool
):
logger, logger_stream = fxt_logger
def mocked_version(_):
return pv.Version("0")
monkeypatch.setattr(Client, "get_server_version", mocked_version)
config = Config()
config.allow_unsupported_server = False
with ExitStack() as es:
if do_check:
es.enter_context(pytest.raises(IncompatibleVersionException))
Client(url=BASE_URL, logger=logger, config=config, check_server_version=do_check)
assert (
"Server version '0' is not compatible with SDK version" in logger_stream.getvalue()
) == do_check
def test_can_check_server_version_in_method(fxt_logger: tuple[Logger, io.StringIO], monkeypatch):
logger, logger_stream = fxt_logger
def mocked_version(_):
return pv.Version("0")
monkeypatch.setattr(Client, "get_server_version", mocked_version)
config = Config()
config.allow_unsupported_server = False
client = Client(url=BASE_URL, logger=logger, config=config, check_server_version=False)
with client, pytest.raises(IncompatibleVersionException):
client.check_server_version()
assert "Server version '0' is not compatible with SDK version" in logger_stream.getvalue()
@pytest.mark.parametrize(
"server_version, supported_versions, expect_supported",
[
# Currently, it is ~=, as defined in https://peps.python.org/pep-0440/
("3.2", ["2.0"], False),
("2", ["2.1"], False),
("2.1", ["2.1"], True),
("2.1a", ["2.1"], False),
("2.1.post1", ["2.1"], True),
("2.1", ["2.1.pre1"], True),
("2.1.1", ["2.1"], True),
("2.2", ["2.1"], False),
("2.2", ["2.1.0", "2.3"], False),
("2.2", ["2.1", "2.2", "2.3"], True),
("2.2.post1", ["2.1", "2.2", "2.3"], True),
("2.2.pre1", ["2.1", "2.2", "2.3"], False),
("2.2", ["2.3"], False),
("2.1.0.dev123", ["2.1.post2"], False),
("1!1.3", ["2.1"], False),
("1!1.3.1", ["2.1", "1!1.3"], True),
("1!1.1.dev12", ["1!1.1"], False),
],
)
def test_can_check_server_version_compatibility(
fxt_logger: tuple[Logger, io.StringIO],
monkeypatch: pytest.MonkeyPatch,
server_version: str,
supported_versions: list[str],
expect_supported: bool,
):
logger, _ = fxt_logger
monkeypatch.setattr(Client, "get_server_version", lambda _: pv.Version(server_version))
monkeypatch.setattr(
Client, "SUPPORTED_SERVER_VERSIONS", [pv.Version(v) for v in supported_versions]
)
config = Config(allow_unsupported_server=False)
with ExitStack() as es:
if not expect_supported:
es.enter_context(pytest.raises(IncompatibleVersionException))
Client(url=BASE_URL, logger=logger, config=config, check_server_version=True)
@pytest.mark.parametrize("verify", [True, False])
def test_can_control_ssl_verification_with_config(verify: bool):
config = Config(verify_ssl=verify)
client = Client(BASE_URL, config=config)
assert client.api_client.configuration.verify_ssl == verify
def test_organization_contexts(admin_user: str):
with make_client(BASE_URL, credentials=(admin_user, USER_PASS)) as client:
assert client.organization_slug is None
org = client.organizations.create(models.OrganizationWriteRequest(slug="testorg"))
# create a project in the personal workspace
client.organization_slug = ""
personal_project = client.projects.create(models.ProjectWriteRequest(name="Personal"))
assert personal_project.organization is None
# create a project in the organization
client.organization_slug = org.slug
org_project = client.projects.create(models.ProjectWriteRequest(name="Org"))
assert org_project.organization == org.id
# both projects should be visible with no context
client.organization_slug = None
client.projects.retrieve(personal_project.id)
client.projects.retrieve(org_project.id)
# retrieve personal and org projects by id
client.organization_slug = ""
client.projects.retrieve(personal_project.id)
client.projects.retrieve(org_project.id)
# org context doesn't make sense for detailed request
client.organization_slug = org.slug
client.projects.retrieve(org_project.id)
client.projects.retrieve(personal_project.id)
@pytest.mark.usefixtures("restore_db_per_function")
def test_organization_filtering(regular_lonely_user: str, fxt_image_file):
with make_client(BASE_URL, credentials=(regular_lonely_user, USER_PASS)) as client:
org = client.organizations.create(models.OrganizationWriteRequest(slug="testorg"))
# create a project and task in sandbox
client.organization_slug = None
client.projects.create(models.ProjectWriteRequest(name="personal_project"))
client.tasks.create_from_data(spec={"name": "personal_task"}, resources=[fxt_image_file])
# create a project and task in the organization
client.organization_slug = org.slug
client.projects.create(models.ProjectWriteRequest(name="org_project"))
client.tasks.create_from_data(spec={"name": "org_task"}, resources=[fxt_image_file])
# return only non-org objects if org parameter is empty
client.organization_slug = ""
projects, tasks, jobs = client.projects.list(), client.tasks.list(), client.jobs.list()
assert len(projects) == len(tasks) == len(jobs) == 1
assert projects[0].organization == tasks[0].organization == jobs[0].organization == None
# return all objects if org parameter wasn't presented
client.organization_slug = None
projects, tasks, jobs = client.projects.list(), client.tasks.list(), client.jobs.list()
assert len(projects) == len(tasks) == len(jobs) == 2
assert {None, org.id} == set([a.organization for a in (*projects, *tasks, *jobs)])
# return only org objects if org parameter is presented and not empty
client.organization_slug = org.slug
projects, tasks, jobs = client.projects.list(), client.tasks.list(), client.jobs.list()
assert len(projects) == len(tasks) == len(jobs) == 1
assert projects[0].organization == tasks[0].organization == jobs[0].organization == org.id
def test_organization_context_manager():
client = Client(BASE_URL)
client.organization_slug = "abc"
with client.organization_context("def"):
assert client.organization_slug == "def"
assert client.organization_slug == "abc"
+416
View File
@@ -0,0 +1,416 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import io
import threading
from logging import Logger
from pathlib import Path
import cvat_sdk.datasets as cvatds
import PIL.Image
import pytest
from cvat_sdk import Client, models
from cvat_sdk.core.proxies.annotations import AnnotationUpdateAction
from cvat_sdk.core.proxies.tasks import ResourceType
from shared.utils.helpers import generate_image_files, generate_video_file
from .util import restrict_api_requests
@pytest.fixture(autouse=True)
def _common_setup(
tmp_path: Path,
fxt_login: tuple[Client, str],
fxt_logger: tuple[Logger, io.StringIO],
restore_redis_ondisk_per_function,
restore_redis_inmem_per_function,
):
logger = fxt_logger[0]
client = fxt_login[0]
client.logger = logger
client.config.cache_dir = tmp_path / "cache"
api_client = client.api_client
for k in api_client.configuration.logger:
api_client.configuration.logger[k] = logger
class TestTaskDataset:
@pytest.fixture(autouse=True)
def setup(
self,
tmp_path: Path,
fxt_login: tuple[Client, str],
):
self.client = fxt_login[0]
self.tmp_path = tmp_path
self.images = generate_image_files(10)
image_dir = tmp_path / "images"
image_dir.mkdir()
image_paths = []
for image in self.images:
image_path = image_dir / image.name
image_path.write_bytes(image.getbuffer())
image_paths.append(image_path)
self.image_paths = image_paths
self.task = self.client.tasks.create_from_data(
models.TaskWriteRequest(
"Dataset layer test task",
labels=[
models.PatchedLabelRequest(name="person"),
models.PatchedLabelRequest(name="car"),
],
),
resource_type=ResourceType.LOCAL,
resources=image_paths,
data_params={"chunk_size": 3},
)
self.expected_labels = sorted(self.task.get_labels(), key=lambda l: l.id)
self.task.update_annotations(
models.PatchedLabeledDataRequest(
tags=[
models.LabeledImageRequest(frame=8, label_id=self.expected_labels[0].id),
models.LabeledImageRequest(frame=8, label_id=self.expected_labels[1].id),
],
shapes=[
models.LabeledShapeRequest(
frame=6,
label_id=self.expected_labels[1].id,
type=models.ShapeType("rectangle"),
points=[1.0, 2.0, 3.0, 4.0],
),
],
),
action=AnnotationUpdateAction.CREATE,
)
@pytest.mark.parametrize("media_download_policy", cvatds.MediaDownloadPolicy)
def test_basic(self, media_download_policy: cvatds.MediaDownloadPolicy):
dataset = cvatds.TaskDataset(
self.client, self.task.id, media_download_policy=media_download_policy
)
# verify that the cache is not empty
assert list(self.client.config.cache_dir.iterdir())
for expected_label, actual_label in zip(
self.expected_labels, sorted(dataset.labels, key=lambda l: l.id)
):
assert expected_label.id == actual_label.id
assert expected_label.name == actual_label.name
assert len(dataset.samples) == self.task.size
for index, sample in enumerate(dataset.samples):
assert sample.frame_index == index
assert sample.frame_name == self.images[index].name
actual_image = sample.media.load_image()
expected_image = PIL.Image.open(self.images[index])
assert actual_image == expected_image
assert not dataset.samples[0].annotations.tags
assert not dataset.samples[1].annotations.shapes
assert {tag.label_id for tag in dataset.samples[8].annotations.tags} == {
label.id for label in self.expected_labels
}
assert not dataset.samples[8].annotations.shapes
assert not dataset.samples[6].annotations.tags
assert len(dataset.samples[6].annotations.shapes) == 1
assert dataset.samples[6].annotations.shapes[0].type.value == "rectangle"
assert dataset.samples[6].annotations.shapes[0].points == [1.0, 2.0, 3.0, 4.0]
@pytest.mark.parametrize("media_download_policy", cvatds.MediaDownloadPolicy)
def test_deleted_frame(self, media_download_policy: cvatds.MediaDownloadPolicy):
self.task.remove_frames_by_ids([1])
dataset = cvatds.TaskDataset(
self.client, self.task.id, media_download_policy=media_download_policy
)
assert len(dataset.samples) == self.task.size - 1
# sample #0 is still frame #0
assert dataset.samples[0].frame_index == 0
assert dataset.samples[0].media.load_image() == PIL.Image.open(self.images[0])
# sample #1 is now frame #2
assert dataset.samples[1].frame_index == 2
assert dataset.samples[1].media.load_image() == PIL.Image.open(self.images[2])
# sample #5 is now frame #6
assert dataset.samples[5].frame_index == 6
assert dataset.samples[5].media.load_image() == PIL.Image.open(self.images[6])
assert len(dataset.samples[5].annotations.shapes) == 1
@pytest.mark.parametrize("media_download_policy", cvatds.MediaDownloadPolicy)
def test_iter_samples_with_deleted_frames(
self, media_download_policy: cvatds.MediaDownloadPolicy
):
deleted_frame_indexes = {1, 3, 8}
self.task.remove_frames_by_ids(sorted(deleted_frame_indexes))
dataset = cvatds.TaskDataset(
self.client,
self.task.id,
load_annotations=False,
media_download_policy=media_download_policy,
)
with dataset.iter_samples() as samples:
for sample, expected_frame_index in zip(
samples,
(index for index in range(len(self.images)) if index not in deleted_frame_indexes),
strict=True,
):
assert sample.frame_index == expected_frame_index
assert sample.media.load_image() == PIL.Image.open(
self.images[expected_frame_index]
)
def test_offline(self, monkeypatch: pytest.MonkeyPatch):
dataset = cvatds.TaskDataset(
self.client,
self.task.id,
update_policy=cvatds.UpdatePolicy.IF_MISSING_OR_STALE,
)
fresh_samples = list(dataset.samples)
restrict_api_requests(monkeypatch)
dataset = cvatds.TaskDataset(
self.client,
self.task.id,
update_policy=cvatds.UpdatePolicy.NEVER,
)
cached_samples = list(dataset.samples)
for fresh_sample, cached_sample in zip(fresh_samples, cached_samples):
assert fresh_sample.frame_index == cached_sample.frame_index
assert fresh_sample.annotations == cached_sample.annotations
assert fresh_sample.media.load_image() == cached_sample.media.load_image()
def test_update(self, monkeypatch: pytest.MonkeyPatch):
dataset = cvatds.TaskDataset(
self.client,
self.task.id,
)
# Recreating the dataset should only result in minimal requests.
restrict_api_requests(
monkeypatch, allow_paths={f"/api/tasks/{self.task.id}", "/api/labels"}
)
dataset = cvatds.TaskDataset(
self.client,
self.task.id,
)
assert dataset.samples[6].annotations.shapes[0].label_id == self.expected_labels[1].id
# After an update, the annotations should be redownloaded.
monkeypatch.undo()
self.task.update_annotations(
models.PatchedLabeledDataRequest(
shapes=[
models.LabeledShapeRequest(
id=dataset.samples[6].annotations.shapes[0].id,
frame=6,
label_id=self.expected_labels[0].id,
type=models.ShapeType("rectangle"),
points=[1.0, 2.0, 3.0, 4.0],
),
]
)
)
dataset = cvatds.TaskDataset(
self.client,
self.task.id,
)
assert dataset.samples[6].annotations.shapes[0].label_id == self.expected_labels[0].id
def test_no_annotations(self):
dataset = cvatds.TaskDataset(self.client, self.task.id, load_annotations=False)
for index, sample in enumerate(dataset.samples):
assert sample.frame_index == index
assert sample.frame_name == self.images[index].name
actual_image = sample.media.load_image()
expected_image = PIL.Image.open(self.images[index])
assert actual_image == expected_image
assert sample.annotations is None
def test_iter_samples_prefetches_and_deletes_finished_chunks(
self, monkeypatch: pytest.MonkeyPatch
):
# Seed the shared cache first. The iterator below uses a temporary chunk directory,
# so these files let the test verify that temporary cleanup does not touch cached chunks.
cvatds.TaskDataset(
self.client,
self.task.id,
load_annotations=False,
media_download_policy=cvatds.MediaDownloadPolicy.PRELOAD_ALL,
)
dataset = cvatds.TaskDataset(
self.client,
self.task.id,
load_annotations=False,
media_download_policy=cvatds.MediaDownloadPolicy.FETCH_CHUNKS_ON_DEMAND,
)
second_chunk_download_started = threading.Event()
allow_second_chunk_download = threading.Event()
observed_temp_chunk_dirs = set()
original_ensure_chunk_in_dir = dataset._ensure_chunk_in_dir
def wrapped_ensure_chunk_in_dir(chunk_dir, chunk_index):
if chunk_dir != dataset._chunk_dir:
observed_temp_chunk_dirs.add(chunk_dir)
# Pause the background prefetch of chunk #1. This keeps chunk #0 readable
# while proving that chunk #1 is being fetched before callers request it.
if chunk_dir != dataset._chunk_dir and chunk_index == 1:
second_chunk_download_started.set()
allow_second_chunk_download.wait()
return original_ensure_chunk_in_dir(chunk_dir, chunk_index)
monkeypatch.setattr(dataset, "_ensure_chunk_in_dir", wrapped_ensure_chunk_in_dir)
chunk_dir = dataset._cache_manager.chunk_dir(self.task.id)
assert (chunk_dir / "0.zip").exists()
assert (chunk_dir / "1.zip").exists()
with dataset.iter_samples(temporary_chunks=True) as samples:
# Reading the first chunk should start downloading the next chunk in the background.
for expected_frame_index in range(3):
sample = next(samples)
assert sample.frame_index == expected_frame_index
assert sample.media.load_image() == PIL.Image.open(
self.images[expected_frame_index]
)
second_chunk_download_started.wait()
assert len(observed_temp_chunk_dirs) == 1
temp_chunk_dir = next(iter(observed_temp_chunk_dirs))
assert temp_chunk_dir != chunk_dir
assert (temp_chunk_dir / "0.zip").exists()
assert not (temp_chunk_dir / "1.zip").exists()
assert (chunk_dir / "0.zip").exists()
assert (chunk_dir / "1.zip").exists()
# Let the background download finish. Advancing into chunk #1 should then delete
# the temporary copy of chunk #0, while leaving the shared cache untouched.
allow_second_chunk_download.set()
fourth_sample = next(samples)
assert fourth_sample.frame_index == 3
assert not (temp_chunk_dir / "0.zip").exists()
assert (chunk_dir / "0.zip").exists()
for _ in samples:
pass
assert not temp_chunk_dir.exists()
def test_iter_samples_works_with_fetch_frames_on_demand(self):
dataset = cvatds.TaskDataset(
self.client,
self.task.id,
load_annotations=False,
media_download_policy=cvatds.MediaDownloadPolicy.FETCH_FRAMES_ON_DEMAND,
)
with dataset.iter_samples() as samples:
for expected_frame_index, sample in zip(range(self.task.size), samples, strict=True):
assert sample.frame_index == expected_frame_index
assert sample.media.load_image() == PIL.Image.open(
self.images[expected_frame_index]
)
def test_iter_samples_rejects_temporary_chunks_with_preload_all(self):
dataset = cvatds.TaskDataset(
self.client,
self.task.id,
load_annotations=False,
media_download_policy=cvatds.MediaDownloadPolicy.PRELOAD_ALL,
)
with pytest.raises(AssertionError):
with dataset.iter_samples(temporary_chunks=True):
pass
def test_non_imageset_video_task_is_unsupported_for_chunk_based_media_access(self):
video_file = generate_video_file(4)
video_path = self.tmp_path / video_file.name
video_path.write_bytes(video_file.getbuffer())
video_task = self.client.tasks.create_from_data(
models.TaskWriteRequest(
"Dataset layer video task",
labels=[models.PatchedLabelRequest(name="video-object")],
),
resource_type=ResourceType.LOCAL,
resources=[video_path],
)
assert video_task.data_original_chunk_type != "imageset"
with pytest.raises(
cvatds.UnsupportedDatasetError,
match="tasks whose original chunks are image sets",
):
cvatds.TaskDataset(
self.client,
video_task.id,
load_annotations=False,
media_download_policy=cvatds.MediaDownloadPolicy.FETCH_CHUNKS_ON_DEMAND,
)
@pytest.mark.parametrize(
"media_download_policy",
[
cvatds.MediaDownloadPolicy.PRELOAD_ALL,
cvatds.MediaDownloadPolicy.FETCH_CHUNKS_ON_DEMAND,
],
)
def test_iter_samples_keeps_files_when_delete_is_disabled(
self, media_download_policy: cvatds.MediaDownloadPolicy
):
dataset = cvatds.TaskDataset(
self.client,
self.task.id,
load_annotations=False,
media_download_policy=media_download_policy,
)
chunk_dir = dataset._cache_manager.chunk_dir(self.task.id)
with dataset.iter_samples(temporary_chunks=False) as samples:
for expected_frame_index, sample in zip(range(self.task.size), samples, strict=True):
assert sample.frame_index == expected_frame_index
assert sample.media.load_image() == PIL.Image.open(
self.images[expected_frame_index]
)
assert all((chunk_dir / f"{chunk_index}.zip").exists() for chunk_index in range(4))
+237
View File
@@ -0,0 +1,237 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import json as _json
from unittest import mock
import pytest
from cvat_sdk.core.filters import (
F,
Filter,
all_,
any_,
build_filter_param,
not_,
pop_lookup_conditions,
)
def test_and_combines_two_filters():
result = (Filter({"==": [{"var": "a"}, 1]}) & Filter({"==": [{"var": "b"}, 2]})).to_json_logic()
assert result == {"and": [{"==": [{"var": "a"}, 1]}, {"==": [{"var": "b"}, 2]}]}
def test_filter_constructor_accepts_conditions():
assert Filter(Filter({"var": "x"})).to_json_logic() == {"var": "x"}
assert Filter('{"var": "x"}').to_json_logic() == {"var": "x"}
def test_filter_constructor_rejects_unsupported_conditions():
with pytest.raises(TypeError):
Filter(1)
with pytest.raises(TypeError):
Filter("[]")
def test_and_flattens_same_operator():
f = (
Filter({"==": [{"var": "a"}, 1]})
& Filter({"==": [{"var": "b"}, 2]})
& Filter({"==": [{"var": "c"}, 3]})
)
assert f.to_json_logic() == {
"and": [{"==": [{"var": "a"}, 1]}, {"==": [{"var": "b"}, 2]}, {"==": [{"var": "c"}, 3]}]
}
def test_or_combines():
f = Filter({"==": [{"var": "a"}, 1]}) | Filter({"==": [{"var": "b"}, 2]})
assert f.to_json_logic() == {"or": [{"==": [{"var": "a"}, 1]}, {"==": [{"var": "b"}, 2]}]}
def test_invert():
assert (~Filter({"==": [{"var": "a"}, 1]})).to_json_logic() == {"!": {"==": [{"var": "a"}, 1]}}
def test_all_single_is_unwrapped():
assert all_(Filter({"var": "x"})).to_json_logic() == {"var": "x"}
def test_all_multiple_wraps_in_and():
assert all_(Filter({"var": "x"}), Filter({"var": "y"})).to_json_logic() == {
"and": [{"var": "x"}, {"var": "y"}]
}
def test_any_multiple_wraps_in_or():
assert any_(Filter({"var": "x"}), Filter({"var": "y"})).to_json_logic() == {
"or": [{"var": "x"}, {"var": "y"}]
}
def test_not_wraps():
assert not_(Filter({"var": "x"})).to_json_logic() == {"!": {"var": "x"}}
def test_all_empty_raises():
with pytest.raises(ValueError):
all_()
def test_field_eq():
assert (F.status == "completed").to_json_logic() == {"==": [{"var": "status"}, "completed"]}
def test_field_ne_uses_not_eq():
assert (F.id != 5).to_json_logic() == {"!": {"==": [{"var": "id"}, 5]}}
def test_field_ordering_operators():
assert (F.id < 5).to_json_logic() == {"<": [{"var": "id"}, 5]}
assert (F.id <= 5).to_json_logic() == {"<=": [{"var": "id"}, 5]}
assert (F.id > 5).to_json_logic() == {">": [{"var": "id"}, 5]}
assert (F.id >= 5).to_json_logic() == {">=": [{"var": "id"}, 5]}
def test_field_one_of():
assert F.project_id.one_of([1, 2, 3]).to_json_logic() == {
"in": [{"var": "project_id"}, [1, 2, 3]]
}
def test_field_contains():
assert F.name.contains("demo").to_json_logic() == {"in": ["demo", {"var": "name"}]}
def test_field_between():
assert F.id.between(10, 20).to_json_logic() == {"<=": [10, {"var": "id"}, 20]}
def test_field_is_set():
assert F.assignee.is_set().to_json_logic() == {"var": "assignee"}
def test_field_getitem():
assert (F["weird-name"] == 1).to_json_logic() == {"==": [{"var": "weird-name"}, 1]}
def test_dsl_composition_example():
f = (F.status == "completed") & F.project_id.one_of([1, 2, 3])
assert f.to_json_logic() == {
"and": [
{"==": [{"var": "status"}, "completed"]},
{"in": [{"var": "project_id"}, [1, 2, 3]]},
]
}
def test_pop_lookup_in():
kwargs = {"project_id__in": [1, 2, 3]}
conditions = pop_lookup_conditions(kwargs)
assert kwargs == {}
assert conditions == [{"in": [{"var": "project_id"}, [1, 2, 3]]}]
def test_pop_lookup_all_operators():
kwargs = {
"name__contains": "demo",
"id__lt": 5,
"id__lte": 5,
"id__gt": 5,
"id__gte": 5,
"status__ne": "completed",
"id__between": (10, 20),
"assignee__isset": True,
}
conditions = pop_lookup_conditions(kwargs)
assert kwargs == {}
assert conditions == [
{"in": ["demo", {"var": "name"}]},
{"<": [{"var": "id"}, 5]},
{"<=": [{"var": "id"}, 5]},
{">": [{"var": "id"}, 5]},
{">=": [{"var": "id"}, 5]},
{"!": {"==": [{"var": "status"}, "completed"]}},
{"<=": [10, {"var": "id"}, 20]},
{"var": "assignee"},
]
def test_pop_lookup_isset_false():
kwargs = {"assignee__isset": False}
assert pop_lookup_conditions(kwargs) == [{"!": {"var": "assignee"}}]
def test_pop_lookup_leaves_plain_and_unknown_kwargs():
kwargs = {"status": "completed", "page_size": 50, "unknown_filter": True}
assert pop_lookup_conditions(kwargs) == []
assert kwargs == {"status": "completed", "page_size": 50, "unknown_filter": True}
def test_build_filter_param_none():
assert build_filter_param(None, []) is None
def test_build_filter_param_single_lookup_unwrapped():
out = build_filter_param(None, [{"var": "x"}])
assert _json.loads(out) == {"var": "x"}
def test_build_filter_param_merges_lookups_and_filter_with_and():
out = build_filter_param(F.name.contains("v2"), [{"==": [{"var": "status"}, "completed"]}])
assert _json.loads(out) == {
"and": [{"==": [{"var": "status"}, "completed"]}, {"in": ["v2", {"var": "name"}]}]
}
def test_build_filter_param_dict_passthrough_serialized():
out = build_filter_param({"==": [{"var": "id"}, 1]}, [])
assert _json.loads(out) == {"==": [{"var": "id"}, 1]}
def test_build_filter_param_string_passthrough_verbatim():
raw = _json.dumps({"==": [{"var": "id"}, 1]})
assert build_filter_param(raw, []) == raw
def _make_fake_repo():
from cvat_sdk.core.proxies import model_proxy
class FakeRepo(model_proxy.ModelListMixin):
def __init__(self):
self.api = mock.Mock()
self._client = mock.Mock()
self._entity_type = lambda client, model: model
return model_proxy, FakeRepo()
def test_list_merges_dsl_and_lookups_into_filter_kwarg():
model_proxy, repo = _make_fake_repo()
with mock.patch.object(model_proxy, "get_paginated_collection", return_value=[]) as gpc:
repo.list(status="completed", project_id__in=[1, 2, 3], filter=F.name.contains("v2"))
forwarded = gpc.call_args.kwargs
# plain kwarg passes through untouched
assert forwarded["status"] == "completed"
# project_id__in was consumed into the filter, not forwarded as a raw kwarg
assert "project_id__in" not in forwarded
assert _json.loads(forwarded["filter"]) == {
"and": [
{"in": [{"var": "project_id"}, [1, 2, 3]]},
{"in": ["v2", {"var": "name"}]},
]
}
def test_list_without_filter_args_sends_no_filter_kwarg():
model_proxy, repo = _make_fake_repo()
with mock.patch.object(model_proxy, "get_paginated_collection", return_value=[]) as gpc:
repo.list(page_size=50)
forwarded = gpc.call_args.kwargs
assert "filter" not in forwarded
assert forwarded["page_size"] == 50
+247
View File
@@ -0,0 +1,247 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import io
from logging import Logger
from pathlib import Path
import pytest
from cvat_sdk import Client
from cvat_sdk.api_client import exceptions, models
from cvat_sdk.core.proxies.tasks import ResourceType, Task
from shared.utils.config import make_sdk_client
class TestIssuesUsecases:
@pytest.fixture(autouse=True)
def setup(
self,
tmp_path: Path,
fxt_login: tuple[Client, str],
fxt_logger: tuple[Logger, io.StringIO],
fxt_stdout: io.StringIO,
):
self.tmp_path = tmp_path
logger, self.logger_stream = fxt_logger
self.stdout = fxt_stdout
self.client, self.user = fxt_login
self.client.logger = logger
api_client = self.client.api_client
for k in api_client.configuration.logger:
api_client.configuration.logger[k] = logger
yield
@pytest.fixture
def fxt_new_task(self, fxt_image_file: Path):
task = self.client.tasks.create_from_data(
spec={"name": "test_task"},
resource_type=ResourceType.LOCAL,
resources=[fxt_image_file],
data_params={"image_quality": 80},
)
return task
def test_can_retrieve_issue(self, fxt_new_task: Task):
issue = self.client.issues.create(
models.IssueWriteRequest(
frame=0,
position=[2.0, 4.0],
job=fxt_new_task.get_jobs()[0].id,
message="hello",
)
)
retrieved_issue = self.client.issues.retrieve(issue.id)
assert issue.id == retrieved_issue.id
assert self.stdout.getvalue() == ""
def test_can_list_issues(self, fxt_new_task: Task):
issue = self.client.issues.create(
models.IssueWriteRequest(
frame=0,
position=[2.0, 4.0],
job=fxt_new_task.get_jobs()[0].id,
message="hello",
assignee=self.client.users.list()[0].id,
)
)
issues = self.client.issues.list()
assert any(issue.id == j.id for j in issues)
assert self.stdout.getvalue() == ""
def test_can_list_comments(self, fxt_new_task: Task):
issue = self.client.issues.create(
models.IssueWriteRequest(
frame=0,
position=[2.0, 4.0],
job=fxt_new_task.get_jobs()[0].id,
message="hello",
)
)
comment = self.client.comments.create(models.CommentWriteRequest(issue.id, message="hi!"))
issue.fetch()
comment_ids = {c.id for c in issue.get_comments()}
assert len(comment_ids) == 2
assert comment.id in comment_ids
assert self.stdout.getvalue() == ""
def test_can_modify_issue(self, fxt_new_task: Task):
issue = self.client.issues.create(
models.IssueWriteRequest(
frame=0,
position=[2.0, 4.0],
job=fxt_new_task.get_jobs()[0].id,
message="hello",
)
)
issue.update(models.PatchedIssueWriteRequest(resolved=True))
retrieved_issue = self.client.issues.retrieve(issue.id)
assert retrieved_issue.resolved is True
assert issue.resolved == retrieved_issue.resolved
assert self.stdout.getvalue() == ""
def test_can_remove_issue(self, fxt_new_task: Task):
issue = self.client.issues.create(
models.IssueWriteRequest(
frame=0,
position=[2.0, 4.0],
job=fxt_new_task.get_jobs()[0].id,
message="hello",
)
)
comments = issue.get_comments()
issue.remove()
with pytest.raises(exceptions.NotFoundException):
issue.fetch()
with pytest.raises(exceptions.NotFoundException):
self.client.comments.retrieve(comments[0].id)
assert self.stdout.getvalue() == ""
class TestCommentsUsecases:
@pytest.fixture(autouse=True)
def setup(
self,
tmp_path: Path,
fxt_login: tuple[Client, str],
fxt_logger: tuple[Logger, io.StringIO],
fxt_stdout: io.StringIO,
):
self.tmp_path = tmp_path
logger, self.logger_stream = fxt_logger
self.stdout = fxt_stdout
self.client, self.user = fxt_login
self.client.logger = logger
api_client = self.client.api_client
for k in api_client.configuration.logger:
api_client.configuration.logger[k] = logger
yield
@pytest.fixture
def fxt_new_task(self, fxt_image_file: Path):
task = self.client.tasks.create_from_data(
spec={"name": "test_task"},
resource_type=ResourceType.LOCAL,
resources=[fxt_image_file],
data_params={"image_quality": 80},
)
return task
def test_can_retrieve_comment(self, fxt_new_task: Task):
issue = self.client.issues.create(
models.IssueWriteRequest(
frame=0,
position=[2.0, 4.0],
job=fxt_new_task.get_jobs()[0].id,
message="hello",
)
)
comment = self.client.comments.create(models.CommentWriteRequest(issue.id, message="hi!"))
retrieved_comment = self.client.comments.retrieve(comment.id)
assert comment.id == retrieved_comment.id
assert self.stdout.getvalue() == ""
def test_can_list_comments(self, fxt_new_task: Task):
issue = self.client.issues.create(
models.IssueWriteRequest(
frame=0,
position=[2.0, 4.0],
job=fxt_new_task.get_jobs()[0].id,
message="hello",
)
)
comment = self.client.comments.create(models.CommentWriteRequest(issue.id, message="hi!"))
comments = self.client.comments.list()
assert any(comment.id == c.id for c in comments)
assert self.stdout.getvalue() == ""
def test_can_modify_comment(self, fxt_new_task: Task):
issue = self.client.issues.create(
models.IssueWriteRequest(
frame=0,
position=[2.0, 4.0],
job=fxt_new_task.get_jobs()[0].id,
message="hello",
)
)
comment = self.client.comments.create(models.CommentWriteRequest(issue.id, message="hi!"))
comment.update(models.PatchedCommentWriteRequest(message="bar"))
retrieved_comment = self.client.comments.retrieve(comment.id)
assert retrieved_comment.message == "bar"
assert comment.message == retrieved_comment.message
assert self.stdout.getvalue() == ""
def test_can_remove_comment(self, fxt_new_task: Task):
issue = self.client.issues.create(
models.IssueWriteRequest(
frame=0,
position=[2.0, 4.0],
job=fxt_new_task.get_jobs()[0].id,
message="hello",
)
)
comment = self.client.comments.create(models.CommentWriteRequest(issue.id, message="hi!"))
comment.remove()
with pytest.raises(exceptions.NotFoundException):
comment.fetch()
assert self.stdout.getvalue() == ""
@pytest.mark.usefixtures("restore_db_per_function")
def test_org_maintainer_can_get_issue_comments_without_explicit_org_context(
fxt_org_resource_hierarchy,
):
resources = fxt_org_resource_hierarchy(include_comment=True)
with make_sdk_client(resources.maintainer_username) as maintainer_client:
issue = maintainer_client.issues.retrieve(resources.issue_id)
comments = issue.get_comments()
assert maintainer_client.organization_slug is None
assert len(comments) == 2
assert resources.comment_id in {comment.id for comment in comments}
+381
View File
@@ -0,0 +1,381 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import io
from logging import Logger
from pathlib import Path
import pytest
from cvat_sdk import Client
from cvat_sdk.api_client import models
from cvat_sdk.core.proxies.tasks import Task
from cvat_sdk.core.proxies.types import Location
from PIL import Image
from pytest_cases import fixture_ref, parametrize
from shared.fixtures.data import CloudStorageAssets
from shared.utils.config import make_sdk_client
from .common import TestDatasetExport
from .util import make_pbar
class TestJobUsecases(TestDatasetExport):
@pytest.fixture(autouse=True)
def setup(
self,
tmp_path: Path,
fxt_login: tuple[Client, str],
fxt_logger: tuple[Logger, io.StringIO],
fxt_stdout: io.StringIO,
restore_redis_ondisk_per_function,
):
self.tmp_path = tmp_path
logger, self.logger_stream = fxt_logger
self.stdout = fxt_stdout
self.client, self.user = fxt_login
self.client.logger = logger
api_client = self.client.api_client
for k in api_client.configuration.logger:
api_client.configuration.logger[k] = logger
yield
@pytest.fixture
def fxt_task_with_shapes(self, fxt_new_task: Task):
labels = fxt_new_task.get_labels()
fxt_new_task.set_annotations(
models.LabeledDataRequest(
shapes=[
models.LabeledShapeRequest(
frame=0,
label_id=labels[0].id,
type="rectangle",
points=[1, 1, 2, 2],
),
],
)
)
return fxt_new_task
def test_can_retrieve_job(self, fxt_new_task: Task):
job_id = fxt_new_task.get_jobs()[0].id
job = self.client.jobs.retrieve(job_id)
assert job.id == job_id
assert self.stdout.getvalue() == ""
def test_can_list_jobs(self, fxt_new_task: Task):
task_job_ids = set(j.id for j in fxt_new_task.get_jobs())
jobs = self.client.jobs.list()
assert len(task_job_ids) != 0
assert task_job_ids.issubset(j.id for j in jobs)
assert self.stdout.getvalue() == ""
def test_can_list_jobs_with_task_filter(self, fxt_new_task: Task):
task_job_ids = {j.id for j in fxt_new_task.get_jobs()}
jobs = self.client.jobs.list(task_id=fxt_new_task.id)
assert task_job_ids
assert {j.id for j in jobs} == task_job_ids
assert self.stdout.getvalue() == ""
def test_can_update_job_field_directly(self, fxt_new_task: Task):
job = self.client.jobs.list()[0]
assert not job.assignee
new_assignee = self.client.users.list()[0]
job.update({"assignee": new_assignee.id})
updated_job = self.client.jobs.retrieve(job.id)
assert updated_job.assignee.id == new_assignee.id
assert self.stdout.getvalue() == ""
def test_can_get_labels(self, fxt_new_task: Task):
expected_labels = {"car", "person"}
received_labels = fxt_new_task.get_jobs()[0].get_labels()
assert {obj.name for obj in received_labels} == expected_labels
assert self.stdout.getvalue() == ""
@pytest.mark.parametrize("format_name", ("CVAT for images 1.1",))
@pytest.mark.parametrize("include_images", (True, False))
@parametrize(
"task, location",
[
(fixture_ref("fxt_new_task"), None),
(fixture_ref("fxt_new_task"), Location.LOCAL),
(
pytest.param(
fixture_ref("fxt_new_task"),
Location.CLOUD_STORAGE,
marks=pytest.mark.with_external_services,
)
),
(
pytest.param(
fixture_ref("fxt_new_task_with_target_storage"),
None,
marks=pytest.mark.with_external_services,
)
),
(fixture_ref("fxt_new_task_with_target_storage"), Location.LOCAL),
(
pytest.param(
fixture_ref("fxt_new_task_with_target_storage"),
Location.CLOUD_STORAGE,
marks=pytest.mark.with_external_services,
)
),
],
)
def test_can_export_dataset(
self,
format_name: str,
include_images: bool,
task: Task,
location: Location | None,
request: pytest.FixtureRequest,
cloud_storages: CloudStorageAssets,
):
job_id = task.get_jobs()[0].id
job = self.client.jobs.retrieve(job_id)
file_path = self.tmp_path / f"job_{job.id}-{format_name.lower()}.zip"
self._test_can_export_dataset(
job,
format_name=format_name,
file_path=file_path,
include_images=include_images,
location=location,
request=request,
cloud_storages=cloud_storages,
)
def test_can_download_preview(self, fxt_new_task: Task):
frame_encoded = fxt_new_task.get_jobs()[0].get_preview()
width, height = Image.open(frame_encoded).size
assert width > 0 and height > 0
assert self.stdout.getvalue() == ""
@pytest.mark.parametrize("quality", ("compressed", "original"))
def test_can_download_frame(self, fxt_new_task: Task, quality: str):
frame_encoded = fxt_new_task.get_jobs()[0].get_frame(0, quality=quality)
width, height = Image.open(frame_encoded).size
assert width > 0 and height > 0
assert self.stdout.getvalue() == ""
@pytest.mark.parametrize("quality", ("compressed", "original"))
@pytest.mark.parametrize("image_extension", (None, "bmp"))
def test_can_download_frames(self, fxt_new_task: Task, quality: str, image_extension: str):
fxt_new_task.get_jobs()[0].download_frames(
[0],
image_extension=image_extension,
quality=quality,
outdir=self.tmp_path,
filename_pattern="frame-{frame_id}{frame_ext}",
)
if image_extension is not None:
expected_frame_ext = image_extension
else:
if quality == "original":
expected_frame_ext = "png"
else:
expected_frame_ext = "jpg"
assert (self.tmp_path / f"frame-0.{expected_frame_ext}").is_file()
assert self.stdout.getvalue() == ""
@pytest.mark.parametrize("convert", [True, False])
def test_can_convert_annotations_polygons_to_masks_param(
self, fxt_new_task: Task, fxt_camvid_dataset: Path, convert: bool
):
pbar_out = io.StringIO()
pbar = make_pbar(file=pbar_out)
fxt_new_task.get_jobs()[0].import_annotations(
format_name="CamVid 1.0",
filename=fxt_camvid_dataset,
pbar=pbar,
conv_mask_to_poly=convert,
)
assert "uploaded" in self.logger_stream.getvalue()
assert "100%" in pbar_out.getvalue().strip("\r").split("\r")[-1]
assert self.stdout.getvalue() == ""
imported_annotations = fxt_new_task.get_jobs()[0].get_annotations()
assert all(
[s.type.value == "polygon" if convert else "mask" for s in imported_annotations.shapes]
)
def test_can_upload_annotations(self, fxt_new_task: Task, fxt_coco_file: Path):
pbar_out = io.StringIO()
pbar = make_pbar(file=pbar_out)
fxt_new_task.get_jobs()[0].import_annotations(
format_name="COCO 1.0", filename=fxt_coco_file, pbar=pbar
)
assert "uploaded" in self.logger_stream.getvalue()
assert "100%" in pbar_out.getvalue().strip("\r").split("\r")[-1]
assert self.stdout.getvalue() == ""
def test_can_get_meta(self, fxt_new_task: Task):
meta = fxt_new_task.get_jobs()[0].get_meta()
assert meta.image_quality == 80
assert meta.size == 1
assert not meta.deleted_frames
assert self.stdout.getvalue() == ""
def test_can_get_frame_info(self, fxt_new_task: Task):
job = meta = fxt_new_task.get_jobs()[0]
meta = job.get_meta()
frames = job.get_frames_info()
assert len(frames) == meta.size
assert frames[0].name == "img.png"
assert frames[0].width == 5
assert frames[0].height == 10
assert self.stdout.getvalue() == ""
def test_can_remove_frames(self, fxt_new_task: Task):
fxt_new_task.get_jobs()[0].remove_frames_by_ids([0])
meta = fxt_new_task.get_jobs()[0].get_meta()
assert meta.deleted_frames == [0]
assert self.stdout.getvalue() == ""
def test_can_get_issues(self, fxt_new_task: Task):
issue = self.client.issues.create(
models.IssueWriteRequest(
frame=0,
position=[2.0, 4.0],
job=fxt_new_task.get_jobs()[0].id,
message="hello",
)
)
job_issue_ids = set(j.id for j in fxt_new_task.get_jobs()[0].get_issues())
assert {issue.id} == job_issue_ids
assert self.stdout.getvalue() == ""
def test_can_get_annotations(self, fxt_task_with_shapes: Task):
anns = fxt_task_with_shapes.get_jobs()[0].get_annotations()
assert len(anns.shapes) == 1
assert anns.shapes[0].type.value == "rectangle"
assert self.stdout.getvalue() == ""
def test_can_set_annotations(self, fxt_new_task: Task):
labels = fxt_new_task.get_labels()
fxt_new_task.get_jobs()[0].set_annotations(
models.LabeledDataRequest(
tags=[models.LabeledImageRequest(frame=0, label_id=labels[0].id)],
)
)
anns = fxt_new_task.get_jobs()[0].get_annotations()
assert len(anns.tags) == 1
assert self.stdout.getvalue() == ""
def test_can_clear_annotations(self, fxt_task_with_shapes: Task):
fxt_task_with_shapes.get_jobs()[0].remove_annotations()
anns = fxt_task_with_shapes.get_jobs()[0].get_annotations()
assert len(anns.tags) == 0
assert len(anns.tracks) == 0
assert len(anns.shapes) == 0
assert self.stdout.getvalue() == ""
def test_can_remove_annotations(self, fxt_new_task: Task):
labels = fxt_new_task.get_labels()
fxt_new_task.get_jobs()[0].set_annotations(
models.LabeledDataRequest(
shapes=[
models.LabeledShapeRequest(
frame=0,
label_id=labels[0].id,
type="rectangle",
points=[1, 1, 2, 2],
),
models.LabeledShapeRequest(
frame=0,
label_id=labels[0].id,
type="rectangle",
points=[2, 2, 3, 3],
),
],
)
)
anns = fxt_new_task.get_jobs()[0].get_annotations()
fxt_new_task.get_jobs()[0].remove_annotations(ids=[anns.shapes[0].id])
anns = fxt_new_task.get_jobs()[0].get_annotations()
assert len(anns.tags) == 0
assert len(anns.tracks) == 0
assert len(anns.shapes) == 1
assert self.stdout.getvalue() == ""
def test_can_update_annotations(self, fxt_task_with_shapes: Task):
labels = fxt_task_with_shapes.get_labels()
fxt_task_with_shapes.get_jobs()[0].update_annotations(
models.PatchedLabeledDataRequest(
shapes=[
models.LabeledShapeRequest(
frame=0,
label_id=labels[0].id,
type="rectangle",
points=[0, 1, 2, 3],
),
],
tracks=[
models.LabeledTrackRequest(
frame=0,
label_id=labels[0].id,
shapes=[
models.TrackedShapeRequest(
frame=0, type="polygon", points=[3, 2, 2, 3, 3, 4]
),
],
)
],
tags=[models.LabeledImageRequest(frame=0, label_id=labels[0].id)],
)
)
anns = fxt_task_with_shapes.get_jobs()[0].get_annotations()
assert len(anns.shapes) == 2
assert len(anns.tracks) == 1
assert len(anns.tags) == 1
assert self.stdout.getvalue() == ""
@pytest.mark.usefixtures("restore_db_per_function")
def test_org_maintainer_can_get_job_resources_without_explicit_org_context(
fxt_org_resource_hierarchy,
):
resources = fxt_org_resource_hierarchy(include_issue=True)
with make_sdk_client(resources.maintainer_username) as maintainer_client:
job = maintainer_client.jobs.retrieve(resources.job_id)
labels = job.get_labels()
issues = job.get_issues()
assert maintainer_client.organization_slug is None
assert {label.name for label in labels} == {"car"}
assert [issue.id for issue in issues] == [resources.issue_id]
+188
View File
@@ -0,0 +1,188 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
from __future__ import annotations
import textwrap
import pytest
try:
import numpy as np
from cvat_sdk.masks import decode_mask, encode_mask
except ModuleNotFoundError as e:
if e.name.split(".")[0] != "numpy":
raise
numpy_installed = False
else:
numpy_installed = True
@pytest.mark.skipif(not numpy_installed, reason="NumPy is not installed")
class TestMasks:
def _bitmap_from_string(self, string: str) -> np.ndarray:
return np.array(
[[bool(int(c)) for c in line] for line in textwrap.dedent(string).splitlines()]
)
def test_encode_mask_without_bbox(self):
bitmap = self._bitmap_from_string("""\
0000000
0001110
0011000
0000000
""")
assert encode_mask(bitmap) == [1, 5, 2, 2, 1, 5, 2]
bitmap = np.zeros((4, 6), dtype=bool)
assert encode_mask(bitmap) == [1, 0, 0, 0, 0]
bitmap = np.ones((4, 6), dtype=bool)
assert encode_mask(bitmap) == [0, 4 * 6, 0, 0, 5, 3]
def test_encode_mask_with_bbox(self):
bitmap = self._bitmap_from_string("""\
001110
011000
""")
bbox = [2.9, 0.9, 4.1, 1.1] # will get rounded to [2, 0, 5, 2]
# There's slightly different logic for when the cropped mask starts with
# 0 and 1, so test both.
# This one starts with 1:
# 111
# 100
assert encode_mask(bitmap, bbox) == [0, 4, 2, 2, 0, 4, 1]
bbox = [1, 0, 5, 2]
# This one starts with 0:
# 0111
# 1100
assert encode_mask(bitmap, bbox) == [1, 5, 2, 1, 0, 4, 1]
bbox = [3, 1, 6, 2] # zeroes only: 000
assert encode_mask(bitmap, bbox) == [3, 3, 1, 5, 1]
bbox = [2, 0, 5, 1] # ones only: 111
assert encode_mask(bitmap, bbox) == [0, 3, 2, 0, 4, 0]
# Edge case: full image
bbox = [0, 0, 6, 2]
assert encode_mask(bitmap, bbox) == [2, 3, 2, 2, 3, 0, 0, 5, 1]
def test_encode_mask_invalid_dim(self):
with pytest.raises(ValueError, match="bitmap must have 2 dimensions"):
encode_mask([True], [0, 0, 1, 1])
def test_encode_mask_invalid_dtype(self):
with pytest.raises(ValueError, match="bitmap must have boolean items"):
encode_mask([[1]], [0, 0, 1, 1])
@pytest.mark.parametrize(
"bbox",
[
[-0.1, 0, 1, 1],
[0, -0.1, 1, 1],
[0, 0, 1.1, 1],
[0, 0, 1, 1.1],
[1, 0, 0, 1],
[0, 1, 1, 0],
[0, 0, 0, 1],
[0, 0, 1, 0],
],
)
def test_encode_mask_invalid_bbox(self, bbox):
with pytest.raises(ValueError, match="bbox has invalid coordinates"):
encode_mask([[True]], bbox)
def test_decode_mask(self):
points = [1, 0, 0, 0, 0]
np.testing.assert_array_equal(
decode_mask(points, image_width=6, image_height=4), np.zeros((4, 6), dtype=bool)
)
points = [0, 24, 0, 0, 5, 3]
np.testing.assert_array_equal(
decode_mask(points, image_width=6, image_height=4),
np.ones((4, 6), dtype=bool),
)
points = [1, 5, 2, 2, 1, 5, 2]
bitmap = self._bitmap_from_string("""\
0000000
0001110
0011000
0000000
""")
np.testing.assert_array_equal(decode_mask(points, image_width=7, image_height=4), bitmap)
# Same mask, but with the bbox covering the whole image.
points = [10, 3, 3, 2, 10, 0, 0, 6, 3]
np.testing.assert_array_equal(decode_mask(points, image_width=7, image_height=4), bitmap)
@pytest.mark.parametrize("image_width, image_height", [(1, 0), (1, -1), (0, 1), (-1, 1)])
def test_decode_mask_invalid_image_dimensions(self, image_width, image_height):
with pytest.raises(ValueError, match="invalid image dimensions"):
decode_mask([1, 0, 0, 0, 0], image_width=image_width, image_height=image_height)
@pytest.mark.parametrize("points", [[], [1, 2, 3, 4]])
def test_decode_mask_invalid_too_few_elements(self, points):
with pytest.raises(ValueError, match="too few elements in encoded mask"):
decode_mask(points, image_width=1, image_height=1)
@pytest.mark.parametrize("points", [[1.1, 2, 3, 4, 5], ["1", 2, 3, 4, 5]])
def test_decode_mask_invalid_non_integer(self, points):
with pytest.raises(ValueError, match="non-integer value in encoded mask"):
decode_mask(points, image_width=1, image_height=1)
@pytest.mark.parametrize(
"points",
[
[9, -1, 1, 3, 3],
[9, 1, -1, 3, 3],
[9, 1, 1, 0, 3],
[9, 1, 1, 3, 0],
[9, 1, 1, 6, 3],
[9, 1, 1, 3, 4],
],
)
def test_decode_mask_invalid_bbox(self, points):
with pytest.raises(ValueError, match="invalid encoded bounding box"):
decode_mask(points, image_width=6, image_height=4)
def test_decode_mask_invalid_mismatched_bbox(self):
with pytest.raises(ValueError, match="encoded bitmap does not match encoded bounding box"):
decode_mask([10, 1, 1, 3, 3], image_width=6, image_height=4)
def _random_subrange(self, rng: np.random.Generator, range_len: int) -> tuple[int, int]:
subrange_len = rng.integers(1, range_len + 1)
start = rng.integers(0, range_len - subrange_len + 1)
return start.item(), (start + subrange_len).item()
@pytest.mark.parametrize("with_bbox", [False, True])
def test_roundtrip(self, with_bbox: bool):
rng = np.random.default_rng(seed=list(b"CVAT"))
for _ in range(100):
width, height = rng.integers(1, 11, size=2)
bitmap = rng.integers(0, 2, size=(height, width), dtype=bool)
if with_bbox:
x1, x2 = self._random_subrange(rng, width)
y1, y2 = self._random_subrange(rng, height)
points = encode_mask(bitmap, [x1, y1, x2, y2])
expected = np.zeros((height, width), dtype=bool)
expected[y1:y2, x1:x2] = bitmap[y1:y2, x1:x2]
else:
points = encode_mask(bitmap)
expected = bitmap
decoded = decode_mask(points, image_width=width, image_height=height)
np.testing.assert_array_equal(decoded, expected)
+83
View File
@@ -0,0 +1,83 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import io
from logging import Logger
import pytest
from cvat_sdk import Client, models
from cvat_sdk.api_client import exceptions
from cvat_sdk.core.proxies.organizations import Organization
class TestOrganizationUsecases:
@pytest.fixture(autouse=True)
def setup(
self,
fxt_login: tuple[Client, str],
fxt_logger: tuple[Logger, io.StringIO],
fxt_stdout: io.StringIO,
):
logger, self.logger_stream = fxt_logger
self.client, self.user = fxt_login
self.client.logger = logger
api_client = self.client.api_client
for k in api_client.configuration.logger:
api_client.configuration.logger[k] = logger
yield
assert fxt_stdout.getvalue() == ""
@pytest.fixture()
def fxt_organization(self) -> Organization:
org = self.client.organizations.create(
models.OrganizationWriteRequest(
slug="testorg",
name="Test Organization",
description="description",
contact={"email": "nowhere@cvat.invalid"},
)
)
try:
yield org
finally:
# It's not allowed to create multiple orgs with the same slug,
# so we have to remove the org at the end of each test.
org.remove()
def test_can_create_organization(self, fxt_organization: Organization):
assert fxt_organization.slug == "testorg"
assert fxt_organization.name == "Test Organization"
assert fxt_organization.description == "description"
assert fxt_organization.contact == {"email": "nowhere@cvat.invalid"}
def test_can_retrieve_organization(self, fxt_organization: Organization):
org = self.client.organizations.retrieve(fxt_organization.id)
assert org.id == fxt_organization.id
assert org.slug == fxt_organization.slug
def test_can_list_organizations(self, fxt_organization: Organization):
orgs = self.client.organizations.list()
assert fxt_organization.slug in set(o.slug for o in orgs)
def test_can_update_organization(self, fxt_organization: Organization):
fxt_organization.update(
models.PatchedOrganizationWriteRequest(description="new description")
)
assert fxt_organization.description == "new description"
retrieved_org = self.client.organizations.retrieve(fxt_organization.id)
assert retrieved_org.description == "new description"
def test_can_remove_organization(self):
org = self.client.organizations.create(models.OrganizationWriteRequest(slug="testorg2"))
org.remove()
with pytest.raises(exceptions.NotFoundException):
org.fetch()
+81
View File
@@ -0,0 +1,81 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import io
import warnings
import tqdm
from cvat_sdk.core.helpers import DeferredTqdmProgressReporter, TqdmProgressReporter
from cvat_sdk.core.progress import NullProgressReporter, ProgressReporter
def _exercise_reporter(r: ProgressReporter) -> None:
with r.task(total=5, desc="Test task", unit="parrots"):
r.advance(1)
r.report_status(4)
for x in r.iter(["x"]):
assert x == "x"
def test_null_reporter():
_exercise_reporter(NullProgressReporter())
# NPR doesn't do anything, so there's nothing to assert
def test_tqdm_reporter():
f = io.StringIO()
instance = tqdm.tqdm(file=f)
with warnings.catch_warnings():
r = TqdmProgressReporter(instance)
_exercise_reporter(r)
output = f.getvalue()
assert "100%" in output
assert "Test task" in output
# TPR doesn't support parameters other than "total" and "desc",
# so there won't be any parrots in the output.
def test_deferred_tqdm_reporter():
f = io.StringIO()
_exercise_reporter(DeferredTqdmProgressReporter({"file": f}))
output = f.getvalue()
assert "100%" in output
assert "Test task" in output
assert "parrots" in output
class _LegacyProgressReporter(ProgressReporter):
# overriding start instead of start2
def start(self, total: int, *, desc: str | None = None) -> None:
self.total = total
self.desc = desc
self.progress = 0
def report_status(self, progress: int):
self.progress = progress
def advance(self, delta: int):
self.progress += delta
def finish(self):
self.finished = True
def test_legacy_progress_reporter():
r = _LegacyProgressReporter()
_exercise_reporter(r)
assert r.total == 5
assert r.desc == "Test task"
assert r.progress == 5
+382
View File
@@ -0,0 +1,382 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import io
from logging import Logger
from pathlib import Path
import pytest
from cvat_sdk import Client, models
from cvat_sdk.api_client import exceptions
from cvat_sdk.core.proxies.projects import Project
from cvat_sdk.core.proxies.tasks import Task
from cvat_sdk.core.proxies.types import Location
from cvat_sdk.core.utils import filter_dict
from PIL import Image
from pytest_cases import fixture_ref, parametrize
from shared.fixtures.data import CloudStorageAssets
from shared.utils.config import IMPORT_EXPORT_BUCKET_ID, make_sdk_client
from .common import TestDatasetExport
from .util import make_pbar
class TestProjectUsecases(TestDatasetExport):
@pytest.fixture(autouse=True)
def setup(
self,
tmp_path: Path,
fxt_login: tuple[Client, str],
fxt_logger: tuple[Logger, io.StringIO],
fxt_stdout: io.StringIO,
restore_redis_ondisk_per_function,
):
self.tmp_path = tmp_path
logger, self.logger_stream = fxt_logger
self.stdout = fxt_stdout
self.client, self.user = fxt_login
self.client.logger = logger
api_client = self.client.api_client
for k in api_client.configuration.logger:
api_client.configuration.logger[k] = logger
@pytest.fixture
def fxt_task_with_shapes(self, fxt_new_task: Task):
labels = fxt_new_task.get_labels()
fxt_new_task.set_annotations(
models.LabeledDataRequest(
shapes=[
models.LabeledShapeRequest(
frame=0,
label_id=labels[0].id,
type="rectangle",
points=[1, 1, 2, 2],
),
],
)
)
return fxt_new_task
@pytest.fixture
def fxt_new_project(self):
project = self.client.projects.create(
spec={
"name": "test_project",
"labels": [{"name": "car"}, {"name": "person"}],
},
)
return project
@pytest.fixture
def fxt_new_project_with_target_storage(self):
project = self.client.projects.create(
spec={
"name": "test_project",
"labels": [{"name": "car"}, {"name": "person"}],
"target_storage": {
"location": Location.CLOUD_STORAGE,
"cloud_storage_id": IMPORT_EXPORT_BUCKET_ID,
},
},
)
return project
@pytest.fixture
def fxt_empty_project(self):
return self.client.projects.create(spec={"name": "test_project"})
@pytest.fixture
def fxt_project_with_shapes(self, fxt_task_with_shapes: Task):
project = self.client.projects.create(
spec=models.ProjectWriteRequest(
name="test_project",
labels=[
models.PatchedLabelRequest(
**filter_dict(label.to_dict(), drop=["id", "has_parent"])
)
for label in fxt_task_with_shapes.get_labels()
],
)
)
fxt_task_with_shapes.update(models.PatchedTaskWriteRequest(project_id=project.id))
project.fetch()
return project
@pytest.fixture
def fxt_backup_file(self, fxt_project_with_shapes: Project):
backup_path = self.tmp_path / "backup.zip"
fxt_project_with_shapes.download_backup(str(backup_path))
yield backup_path
def test_can_create_empty_project(self):
project = self.client.projects.create(spec=models.ProjectWriteRequest(name="test project"))
assert project.id != 0
assert project.name == "test project"
def test_can_create_project_with_attribute_with_blank_default(self):
project = self.client.projects.create(
spec=models.ProjectWriteRequest(
name="test project",
labels=[
models.PatchedLabelRequest(
name="text",
attributes=[
models.AttributeRequest(
name="text",
mutable=True,
input_type=models.InputTypeEnum("text"),
values=[],
default_value="",
)
],
)
],
)
)
labels = project.get_labels()
assert labels[0].attributes[0].default_value == ""
def test_can_create_project_from_dataset(self, fxt_coco_dataset: Path):
pbar_out = io.StringIO()
pbar = make_pbar(file=pbar_out)
project = self.client.projects.create_from_dataset(
spec=models.ProjectWriteRequest(name="project with data"),
dataset_path=fxt_coco_dataset,
dataset_format="COCO 1.0",
pbar=pbar,
)
assert project.get_tasks()[0].size == 1
assert "100%" in pbar_out.getvalue().strip("\r").split("\r")[-1]
assert self.stdout.getvalue() == ""
@pytest.mark.parametrize("convert", [True, False])
def test_can_create_project_from_dataset_with_polygons_to_masks_param(
self, fxt_camvid_dataset: Path, convert: bool
):
pbar_out = io.StringIO()
pbar = make_pbar(file=pbar_out)
project = self.client.projects.create_from_dataset(
spec=models.ProjectWriteRequest(name="project with data"),
dataset_path=fxt_camvid_dataset,
dataset_format="CamVid 1.0",
conv_mask_to_poly=convert,
pbar=pbar,
)
assert project.get_tasks()[0].size == 1
assert "100%" in pbar_out.getvalue().strip("\r").split("\r")[-1]
assert self.stdout.getvalue() == ""
task = project.get_tasks()[0]
imported_annotations = task.get_annotations()
assert all(
[s.type.value == "polygon" if convert else "mask" for s in imported_annotations.shapes]
)
def test_can_retrieve_project(self, fxt_new_project: Project):
project_id = fxt_new_project.id
project = self.client.projects.retrieve(project_id)
assert project.id == project_id
assert self.stdout.getvalue() == ""
def test_can_list_projects(self, fxt_new_project: Project):
project_id = fxt_new_project.id
projects = self.client.projects.list()
assert any(p.id == project_id for p in projects)
assert self.stdout.getvalue() == ""
def test_can_update_project(self, fxt_new_project: Project):
fxt_new_project.update(models.PatchedProjectWriteRequest(name="foo"))
retrieved_project = self.client.projects.retrieve(fxt_new_project.id)
assert retrieved_project.name == "foo"
assert fxt_new_project.name == retrieved_project.name
assert self.stdout.getvalue() == ""
def test_can_delete_project(self, fxt_new_project: Project):
fxt_new_project.remove()
with pytest.raises(exceptions.NotFoundException):
fxt_new_project.fetch()
assert self.stdout.getvalue() == ""
def test_can_get_tasks(self, fxt_project_with_shapes: Project):
tasks = fxt_project_with_shapes.get_tasks()
assert len(tasks) == 1
assert tasks[0].project_id == fxt_project_with_shapes.id
def test_can_get_labels(self, fxt_project_with_shapes: Project):
expected_labels = {"car", "person"}
received_labels = fxt_project_with_shapes.get_labels()
assert {obj.name for obj in received_labels} == expected_labels
assert self.stdout.getvalue() == ""
def test_can_download_backup(self, fxt_project_with_shapes: Project):
pbar_out = io.StringIO()
pbar = make_pbar(file=pbar_out)
backup_path = self.tmp_path / "backup.zip"
fxt_project_with_shapes.download_backup(str(backup_path), pbar=pbar)
assert backup_path.stat().st_size > 0
assert "100%" in pbar_out.getvalue().strip("\r").split("\r")[-1]
assert self.stdout.getvalue() == ""
def test_can_create_from_backup(self, fxt_backup_file: Path):
pbar_out = io.StringIO()
pbar = make_pbar(file=pbar_out)
restored_project = self.client.projects.create_from_backup(fxt_backup_file, pbar=pbar)
assert restored_project.get_tasks()[0].size == 1
assert "100%" in pbar_out.getvalue().strip("\r").split("\r")[-1]
assert self.stdout.getvalue() == ""
@pytest.mark.parametrize("format_name", ("CVAT for images 1.1",))
@pytest.mark.parametrize("include_images", (True, False))
@parametrize(
"project, location",
[
(fixture_ref("fxt_new_project"), None),
(fixture_ref("fxt_new_project"), Location.LOCAL),
(
pytest.param(
fixture_ref("fxt_new_project"),
Location.CLOUD_STORAGE,
marks=pytest.mark.with_external_services,
)
),
(
pytest.param(
fixture_ref("fxt_new_project_with_target_storage"),
None,
marks=pytest.mark.with_external_services,
)
),
(fixture_ref("fxt_new_project_with_target_storage"), Location.LOCAL),
(
pytest.param(
fixture_ref("fxt_new_project_with_target_storage"),
Location.CLOUD_STORAGE,
marks=pytest.mark.with_external_services,
)
),
],
)
def test_can_export_dataset(
self,
format_name: str,
include_images: bool,
project: Project,
location: Location | None,
request: pytest.FixtureRequest,
cloud_storages: CloudStorageAssets,
):
file_path = self.tmp_path / f"project_{project.id}-{format_name.lower()}.zip"
self._test_can_export_dataset(
project,
format_name=format_name,
file_path=file_path,
include_images=include_images,
location=location,
request=request,
cloud_storages=cloud_storages,
)
def test_can_download_preview(self, fxt_project_with_shapes: Project):
frame_encoded = fxt_project_with_shapes.get_preview()
width, height = Image.open(frame_encoded).size
assert width > 0 and height > 0
assert self.stdout.getvalue() == ""
@pytest.mark.usefixtures("restore_db_per_function")
def test_can_get_personal_project_resources_while_client_is_scoped_to_org(
admin_user: str,
fxt_image_file: Path,
monkeypatch: pytest.MonkeyPatch,
):
with make_sdk_client(admin_user) as client:
org = client.organizations.create(models.OrganizationWriteRequest(slug="testorg"))
client.organization_slug = ""
project = client.projects.create(
spec=models.ProjectWriteRequest(
name="personal project",
labels=[models.PatchedLabelRequest(name="car")],
)
)
client.tasks.create_from_data(
spec=models.TaskWriteRequest(name="personal task", project_id=project.id),
resources=[fxt_image_file],
data_params={"image_quality": 80},
)
client.organization_slug = org.slug
project = client.projects.retrieve(project.id)
monkeypatch.setattr(
client,
"organization_context",
lambda *_args, **_kwargs: pytest.fail(
"organization_context should not be used for project resource listing"
),
)
tasks = project.get_tasks()
labels = project.get_labels()
assert client.organization_slug == org.slug
assert len(tasks) == 1
assert tasks[0].project_id == project.id
assert {label.name for label in labels} == {"car"}
@pytest.mark.usefixtures("restore_db_per_function")
def test_org_maintainer_can_get_project_resources_without_explicit_org_context(
fxt_org_resource_hierarchy,
monkeypatch: pytest.MonkeyPatch,
):
resources = fxt_org_resource_hierarchy()
with make_sdk_client(resources.maintainer_username) as maintainer_client:
monkeypatch.setattr(
maintainer_client.organizations,
"retrieve",
lambda *_args, **_kwargs: pytest.fail("organization lookup is not expected here"),
)
monkeypatch.setattr(
maintainer_client,
"organization_context",
lambda *_args, **_kwargs: pytest.fail(
"organization_context is not expected for project resource listing"
),
)
project = maintainer_client.projects.retrieve(resources.project_id)
tasks = project.get_tasks()
labels = project.get_labels()
assert maintainer_client.organization_slug is None
assert len(tasks) == 1
assert tasks[0].project_id == resources.project_id
assert {label.name for label in labels} == {"car"}
+581
View File
@@ -0,0 +1,581 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import io
import itertools
import os
from logging import Logger
from pathlib import Path
import pytest
from cvat_sdk import Client, models
from cvat_sdk.core.proxies.annotations import AnnotationUpdateAction
from cvat_sdk.core.proxies.tasks import ResourceType
try:
import cvat_sdk.pytorch as cvatpt
import PIL.Image
import torch
import torchvision.transforms
import torchvision.transforms.functional as TF
from torch.utils.data import DataLoader
except ModuleNotFoundError as e:
if e.name.split(".")[0] not in {"torch", "torchvision"}:
raise
cvatpt = None
from shared.utils.helpers import generate_image_files
from .util import restrict_api_requests
@pytest.fixture(autouse=True)
def _common_setup(
tmp_path: Path,
fxt_login: tuple[Client, str],
fxt_logger: tuple[Logger, io.StringIO],
restore_redis_ondisk_per_function,
restore_redis_inmem_per_function,
):
logger = fxt_logger[0]
client = fxt_login[0]
client.logger = logger
client.config.cache_dir = tmp_path / "cache"
api_client = client.api_client
for k in api_client.configuration.logger:
api_client.configuration.logger[k] = logger
@pytest.mark.skipif(cvatpt is None, reason="PyTorch dependencies are not installed")
class TestTaskVisionDataset:
@pytest.fixture(autouse=True)
def setup(
self,
tmp_path: Path,
fxt_login: tuple[Client, str],
):
self.client = fxt_login[0]
self.images = generate_image_files(10)
image_dir = tmp_path / "images"
image_dir.mkdir()
image_paths = []
for image in self.images:
image_path = image_dir / image.name
image_path.write_bytes(image.getbuffer())
image_paths.append(image_path)
self.task = self.client.tasks.create_from_data(
models.TaskWriteRequest(
"PyTorch integration test task",
labels=[
models.PatchedLabelRequest(name="person"),
models.PatchedLabelRequest(name="car"),
],
),
resource_type=ResourceType.LOCAL,
resources=list(map(os.fspath, image_paths)),
data_params={"chunk_size": 3},
)
self.label_ids = sorted(l.id for l in self.task.get_labels())
self.task.update_annotations(
models.PatchedLabeledDataRequest(
tags=[
models.LabeledImageRequest(frame=5, label_id=self.label_ids[0]),
models.LabeledImageRequest(frame=6, label_id=self.label_ids[1]),
models.LabeledImageRequest(frame=8, label_id=self.label_ids[0]),
models.LabeledImageRequest(frame=8, label_id=self.label_ids[1]),
],
shapes=[
models.LabeledShapeRequest(
frame=6,
label_id=self.label_ids[1],
type=models.ShapeType("rectangle"),
points=[1.0, 2.0, 3.0, 4.0],
),
models.LabeledShapeRequest(
frame=7,
label_id=self.label_ids[0],
type=models.ShapeType("points"),
points=[1.1, 2.1, 3.1, 4.1],
),
models.LabeledShapeRequest(
frame=9,
label_id=self.label_ids[0],
type=models.ShapeType("polygon"),
points=[1.0, 1.0, 4.0, 1.0, 4.0, 3.0, 1.0, 3.0],
),
],
),
action=AnnotationUpdateAction.CREATE,
)
def test_basic(self):
dataset = cvatpt.TaskVisionDataset(self.client, self.task.id)
# verify that the cache is not empty
assert list(self.client.config.cache_dir.iterdir())
assert len(dataset) == self.task.size
for index, (sample_image, sample_target) in enumerate(dataset):
sample_image_tensor = TF.pil_to_tensor(sample_image)
reference_tensor = TF.pil_to_tensor(PIL.Image.open(self.images[index]))
assert torch.equal(sample_image_tensor, reference_tensor)
assert sample_target.image_size == sample_image.size
for index, label_id in enumerate(self.label_ids):
assert sample_target.label_id_to_index[label_id] == index
assert not dataset[0][1].annotations.tags
assert not dataset[0][1].annotations.shapes
assert len(dataset[5][1].annotations.tags) == 1
assert dataset[5][1].annotations.tags[0].label_id == self.label_ids[0]
assert not dataset[5][1].annotations.shapes
assert len(dataset[6][1].annotations.tags) == 1
assert dataset[6][1].annotations.tags[0].label_id == self.label_ids[1]
assert len(dataset[6][1].annotations.shapes) == 1
assert dataset[6][1].annotations.shapes[0].type.value == "rectangle"
assert dataset[6][1].annotations.shapes[0].points == [1.0, 2.0, 3.0, 4.0]
assert not dataset[7][1].annotations.tags
assert len(dataset[7][1].annotations.shapes) == 1
assert dataset[7][1].annotations.shapes[0].type.value == "points"
assert dataset[7][1].annotations.shapes[0].points == [1.1, 2.1, 3.1, 4.1]
def test_deleted_frame(self):
self.task.remove_frames_by_ids([1])
dataset = cvatpt.TaskVisionDataset(self.client, self.task.id)
assert len(dataset) == self.task.size - 1
# sample #0 is still frame #0
assert torch.equal(
TF.pil_to_tensor(dataset[0][0]), TF.pil_to_tensor(PIL.Image.open(self.images[0]))
)
# sample #1 is now frame #2
assert torch.equal(
TF.pil_to_tensor(dataset[1][0]), TF.pil_to_tensor(PIL.Image.open(self.images[2]))
)
# sample #4 is now frame #5
assert len(dataset[4][1].annotations.tags) == 1
assert dataset[4][1].annotations.tags[0].label_id == self.label_ids[0]
assert not dataset[4][1].annotations.shapes
def test_extract_single_label_index(self):
dataset = cvatpt.TaskVisionDataset(
self.client,
self.task.id,
transform=torchvision.transforms.PILToTensor(),
target_transform=cvatpt.ExtractSingleLabelIndex(),
)
assert torch.equal(dataset[5][1], torch.tensor(0))
assert torch.equal(dataset[6][1], torch.tensor(1))
with pytest.raises(ValueError):
# no tags
_ = dataset[7]
with pytest.raises(ValueError):
# multiple tags
_ = dataset[8]
# make sure the samples can be batched with the default collator
loader = DataLoader(dataset, batch_size=2, sampler=[5, 6])
batch = next(iter(loader))
assert torch.equal(batch[0][0], TF.pil_to_tensor(PIL.Image.open(self.images[5])))
assert torch.equal(batch[0][1], TF.pil_to_tensor(PIL.Image.open(self.images[6])))
assert torch.equal(batch[1], torch.tensor([0, 1]))
def test_extract_bounding_boxes(self):
dataset = cvatpt.TaskVisionDataset(
self.client,
self.task.id,
transform=torchvision.transforms.PILToTensor(),
target_transform=cvatpt.ExtractBoundingBoxes(include_shape_types={"rectangle"}),
)
assert dataset[0][1]["boxes"].shape == (0, 4)
assert dataset[0][1]["boxes"].dtype == torch.float
assert dataset[0][1]["labels"].shape == (0,)
assert dataset[0][1]["labels"].dtype == torch.long
assert torch.equal(dataset[6][1]["boxes"], torch.tensor([(1.0, 2.0, 3.0, 4.0)]))
assert torch.equal(dataset[6][1]["labels"], torch.tensor([1]))
# points are filtered out
assert dataset[7][1]["boxes"].shape == (0, 4)
assert dataset[7][1]["boxes"].dtype == torch.float
assert dataset[7][1]["labels"].shape == (0,)
assert dataset[7][1]["labels"].dtype == torch.long
def test_extract_instance_masks_target_transform(self):
dataset = cvatpt.TaskVisionDataset(
self.client,
self.task.id,
transform=torchvision.transforms.PILToTensor(),
target_transform=cvatpt.ExtractInstanceMasks(include_shape_types={"polygon", "mask"}),
)
assert dataset[0][1]["boxes"].shape == (0, 4)
assert dataset[0][1]["boxes"].dtype == torch.float
assert dataset[0][1]["labels"].shape == (0,)
assert dataset[0][1]["labels"].dtype == torch.long
assert dataset[0][1]["masks"].shape == (0, 50, 100)
assert dataset[0][1]["masks"].dtype == torch.uint8
# rectangles are filtered out
assert dataset[6][1]["boxes"].shape == (0, 4)
assert dataset[6][1]["labels"].shape == (0,)
assert dataset[6][1]["labels"].dtype == torch.long
assert dataset[6][1]["masks"].shape == (0, 50, 100)
assert torch.equal(dataset[9][1]["boxes"], torch.tensor([(1.0, 1.0, 4.0, 3.0)]))
assert torch.equal(dataset[9][1]["labels"], torch.tensor([0]))
expected_mask = torch.zeros((50, 100), dtype=torch.uint8)
expected_mask[1:4, 1:5] = 1
assert torch.equal(dataset[9][1]["masks"], expected_mask.unsqueeze(0))
def test_extract_instance_masks(self):
from cvat_sdk.masks import encode_mask
width, height = 6, 5
label_id_to_index = {self.label_ids[0]: 0, self.label_ids[1]: 1}
bitmap = torch.zeros((height, width), dtype=torch.bool)
bitmap[1:3, 2:5] = True
target = cvatpt.Target(
annotations=cvatpt.FrameAnnotations(
shapes=[
models.LabeledShape(
frame=0,
label_id=self.label_ids[0],
type=models.ShapeType("polygon"),
points=[1.0, 1.0, 4.0, 1.0, 4.0, 3.0, 1.0, 3.0],
rotation=0,
),
models.LabeledShape(
frame=0,
label_id=self.label_ids[1],
type=models.ShapeType("mask"),
points=encode_mask(bitmap.numpy()),
rotation=0,
),
models.LabeledShape(
frame=0,
label_id=self.label_ids[1],
type=models.ShapeType("points"),
points=[0.0, 0.0],
rotation=0,
),
],
),
label_id_to_index=label_id_to_index,
image_size=(width, height),
)
transformed = cvatpt.ExtractInstanceMasks(include_shape_types={"polygon", "mask"})(target)
assert torch.equal(
transformed["boxes"],
torch.tensor([(1.0, 1.0, 4.0, 3.0), (2.0, 1.0, 5.0, 3.0)]),
)
assert torch.equal(transformed["labels"], torch.tensor([0, 1]))
expected_polygon = torch.zeros((height, width), dtype=torch.uint8)
expected_polygon[1:4, 1:5] = 1
assert torch.equal(transformed["masks"][0], expected_polygon)
assert torch.equal(transformed["masks"][1], bitmap.to(torch.uint8))
def test_extract_instance_masks_empty(self):
target = cvatpt.Target(
annotations=cvatpt.FrameAnnotations(),
label_id_to_index={},
image_size=(6, 5),
)
transformed = cvatpt.ExtractInstanceMasks(include_shape_types={"polygon", "mask"})(target)
assert transformed["boxes"].shape == (0, 4)
assert transformed["boxes"].dtype == torch.float
assert transformed["labels"].shape == (0,)
assert transformed["labels"].dtype == torch.long
assert transformed["masks"].shape == (0, 5, 6)
assert transformed["masks"].dtype == torch.uint8
def test_extract_instance_masks_rejects_unsupported_shape_types(self):
with pytest.raises(ValueError):
cvatpt.ExtractInstanceMasks(include_shape_types={"rectangle"})
def test_extract_instance_masks_rejects_rotated_shapes(self):
target = cvatpt.Target(
annotations=cvatpt.FrameAnnotations(
shapes=[
models.LabeledShape(
frame=0,
label_id=self.label_ids[0],
type=models.ShapeType("polygon"),
points=[1.0, 1.0, 4.0, 1.0, 4.0, 3.0],
rotation=1,
),
],
),
label_id_to_index={self.label_ids[0]: 0},
image_size=(6, 5),
)
with pytest.raises(cvatpt.UnsupportedDatasetError, match="Rotated shapes"):
cvatpt.ExtractInstanceMasks(include_shape_types={"polygon"})(target)
def test_transforms(self):
dataset = cvatpt.TaskVisionDataset(
self.client,
self.task.id,
transforms=lambda x, y: (y, x),
)
assert isinstance(dataset[0][0], cvatpt.Target)
assert isinstance(dataset[0][1], PIL.Image.Image)
def test_custom_label_mapping(self):
label_name_to_id = {label.name: label.id for label in self.task.get_labels()}
dataset = cvatpt.TaskVisionDataset(
self.client,
self.task.id,
label_name_to_index={"person": 123, "car": 456},
)
_, target = dataset[5]
assert target.label_id_to_index[label_name_to_id["person"]] == 123
assert target.label_id_to_index[label_name_to_id["car"]] == 456
def test_offline(self, monkeypatch: pytest.MonkeyPatch):
dataset = cvatpt.TaskVisionDataset(
self.client,
self.task.id,
update_policy=cvatpt.UpdatePolicy.IF_MISSING_OR_STALE,
)
fresh_samples = list(dataset)
restrict_api_requests(monkeypatch)
dataset = cvatpt.TaskVisionDataset(
self.client,
self.task.id,
update_policy=cvatpt.UpdatePolicy.NEVER,
)
cached_samples = list(dataset)
assert fresh_samples == cached_samples
def test_update(self, monkeypatch: pytest.MonkeyPatch):
dataset = cvatpt.TaskVisionDataset(
self.client,
self.task.id,
)
# Recreating the dataset should only result in minimal requests.
restrict_api_requests(
monkeypatch, allow_paths={f"/api/tasks/{self.task.id}", "/api/labels"}
)
dataset = cvatpt.TaskVisionDataset(
self.client,
self.task.id,
)
assert dataset[5][1].annotations.tags[0].label_id == self.label_ids[0]
# After an update, the annotations should be redownloaded.
monkeypatch.undo()
self.task.update_annotations(
models.PatchedLabeledDataRequest(
tags=[
models.LabeledImageRequest(
id=dataset[5][1].annotations.tags[0].id, frame=5, label_id=self.label_ids[1]
),
]
)
)
dataset = cvatpt.TaskVisionDataset(
self.client,
self.task.id,
)
assert dataset[5][1].annotations.tags[0].label_id == self.label_ids[1]
@pytest.mark.skipif(cvatpt is None, reason="PyTorch dependencies are not installed")
class TestProjectVisionDataset:
@pytest.fixture(autouse=True)
def setup(
self,
tmp_path: Path,
fxt_login: tuple[Client, str],
):
self.client = fxt_login[0]
self.project = self.client.projects.create(
models.ProjectWriteRequest(
"PyTorch integration test project",
labels=[
models.PatchedLabelRequest(name="person"),
models.PatchedLabelRequest(name="car"),
],
)
)
self.label_ids = sorted(l.id for l in self.project.get_labels())
subsets = ["Train", "Test", "Val"]
num_images_per_task = 3
all_images = generate_image_files(num_images_per_task * len(subsets))
self.images_per_task = list(zip(*[iter(all_images)] * num_images_per_task))
image_dir = tmp_path / "images"
image_dir.mkdir()
image_paths_per_task = []
for images in self.images_per_task:
image_paths = []
for image in images:
image_path = image_dir / image.name
image_path.write_bytes(image.getbuffer())
image_paths.append(image_path)
image_paths_per_task.append(image_paths)
self.tasks = [
self.client.tasks.create_from_data(
models.TaskWriteRequest(
"PyTorch integration test task",
project_id=self.project.id,
subset=subset,
),
resource_type=ResourceType.LOCAL,
resources=image_paths,
data_params={"image_quality": 70},
)
for subset, image_paths in zip(subsets, image_paths_per_task)
]
# sort both self.tasks and self.images_per_task in the order that ProjectVisionDataset uses
self.tasks, self.images_per_task = zip(
*sorted(zip(self.tasks, self.images_per_task), key=lambda t: t[0].id)
)
for task_id, label_index in ((0, 0), (1, 1), (2, 0)):
self.tasks[task_id].update_annotations(
models.PatchedLabeledDataRequest(
tags=[
models.LabeledImageRequest(
frame=task_id, label_id=self.label_ids[label_index]
),
],
)
)
def test_basic(self):
dataset = cvatpt.ProjectVisionDataset(self.client, self.project.id)
assert len(dataset) == sum(task.size for task in self.tasks)
for sample, image in zip(dataset, itertools.chain.from_iterable(self.images_per_task)):
assert torch.equal(TF.pil_to_tensor(sample[0]), TF.pil_to_tensor(PIL.Image.open(image)))
assert dataset[0][1].annotations.tags[0].label_id == self.label_ids[0]
assert dataset[4][1].annotations.tags[0].label_id == self.label_ids[1]
assert dataset[8][1].annotations.tags[0].label_id == self.label_ids[0]
def _test_filtering(self, **kwargs):
dataset = cvatpt.ProjectVisionDataset(self.client, self.project.id, **kwargs)
assert len(dataset) == sum(task.size for task in self.tasks[1:])
for sample, image in zip(dataset, itertools.chain.from_iterable(self.images_per_task[1:])):
assert torch.equal(TF.pil_to_tensor(sample[0]), TF.pil_to_tensor(PIL.Image.open(image)))
assert dataset[1][1].annotations.tags[0].label_id == self.label_ids[1]
assert dataset[5][1].annotations.tags[0].label_id == self.label_ids[0]
def test_task_filter(self):
self._test_filtering(task_filter=lambda t: t.subset != self.tasks[0].subset)
def test_include_subsets(self):
self._test_filtering(include_subsets={self.tasks[1].subset, self.tasks[2].subset})
def test_custom_label_mapping(self):
label_name_to_id = {label.name: label.id for label in self.project.get_labels()}
dataset = cvatpt.ProjectVisionDataset(
self.client, self.project.id, label_name_to_index={"person": 123, "car": 456}
)
_, target = dataset[5]
assert target.label_id_to_index[label_name_to_id["person"]] == 123
assert target.label_id_to_index[label_name_to_id["car"]] == 456
def test_separate_transforms(self):
dataset = cvatpt.ProjectVisionDataset(
self.client,
self.project.id,
transform=torchvision.transforms.ToTensor(),
target_transform=cvatpt.ExtractSingleLabelIndex(),
)
assert torch.equal(
dataset[0][0], TF.pil_to_tensor(PIL.Image.open(self.images_per_task[0][0]))
)
assert torch.equal(dataset[0][1], torch.tensor(0))
def test_combined_transforms(self):
dataset = cvatpt.ProjectVisionDataset(
self.client,
self.project.id,
transforms=lambda x, y: (y, x),
)
assert isinstance(dataset[0][0], cvatpt.Target)
assert isinstance(dataset[0][1], PIL.Image.Image)
def test_offline(self, monkeypatch: pytest.MonkeyPatch):
dataset = cvatpt.ProjectVisionDataset(
self.client,
self.project.id,
update_policy=cvatpt.UpdatePolicy.IF_MISSING_OR_STALE,
)
fresh_samples = list(dataset)
restrict_api_requests(monkeypatch)
dataset = cvatpt.ProjectVisionDataset(
self.client,
self.project.id,
update_policy=cvatpt.UpdatePolicy.NEVER,
)
cached_samples = list(dataset)
assert fresh_samples == cached_samples
+677
View File
@@ -0,0 +1,677 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import io
import json
import os.path as osp
import zipfile
from logging import Logger
from pathlib import Path
from unittest import mock
import pytest
from cvat_sdk import Client, models
from cvat_sdk.api_client import exceptions
from cvat_sdk.api_client.rest import RESTClientObject
from cvat_sdk.core.exceptions import BackgroundRequestException
from cvat_sdk.core.filters import F
from cvat_sdk.core.proxies.tasks import ResourceType, Task
from cvat_sdk.core.proxies.types import Location
from PIL import Image
from pytest_cases import fixture_ref, parametrize
from shared.fixtures.data import CloudStorageAssets
from shared.utils.config import make_sdk_client
from shared.utils.helpers import generate_image_files
from .common import TestDatasetExport
from .util import make_pbar
class TestTaskUsecases(TestDatasetExport):
@pytest.fixture(autouse=True)
def setup(
self,
tmp_path: Path,
fxt_login: tuple[Client, str],
fxt_logger: tuple[Logger, io.StringIO],
fxt_stdout: io.StringIO,
restore_redis_ondisk_per_function,
):
self.tmp_path = tmp_path
logger, self.logger_stream = fxt_logger
self.stdout = fxt_stdout
self.client, self.user = fxt_login
self.client.logger = logger
api_client = self.client.api_client
for k in api_client.configuration.logger:
api_client.configuration.logger[k] = logger
yield
@pytest.fixture
def fxt_backup_file(self, fxt_new_task: Task, fxt_coco_file: str):
backup_path = self.tmp_path / "backup.zip"
fxt_new_task.import_annotations("COCO 1.0", filename=fxt_coco_file)
fxt_new_task.download_backup(backup_path)
yield backup_path
@pytest.fixture
def fxt_new_task_without_data(self):
task = self.client.tasks.create(spec={"name": "test_task"})
return task
@pytest.fixture
def fxt_task_with_shapes(self, fxt_new_task: Task):
labels = fxt_new_task.get_labels()
fxt_new_task.set_annotations(
models.LabeledDataRequest(
shapes=[
models.LabeledShapeRequest(
frame=0,
label_id=labels[0].id,
type="rectangle",
points=[1, 1, 2, 2],
),
],
)
)
return fxt_new_task
def test_can_create_task_with_local_data(self):
pbar_out = io.StringIO()
pbar = make_pbar(file=pbar_out)
task_spec = {
"name": f"test {self.user} to create a task with local data",
}
data_params = {
"image_quality": 75,
}
task_files = generate_image_files(7)
for i, f in enumerate(task_files):
fname = self.tmp_path / f.name
fname.write_bytes(f.getvalue())
task_files[i] = fname
task = self.client.tasks.create_from_data(
spec=task_spec,
data_params=data_params,
resources=task_files,
pbar=pbar,
)
assert task.size == 7
assert "100%" in pbar_out.getvalue().strip("\r").split("\r")[-1]
assert self.stdout.getvalue() == ""
def test_can_create_task_with_local_data_and_predefined_sorting(
self, fxt_new_task_without_data: Task
):
task = fxt_new_task_without_data
task_files = generate_image_files(6)
task_filenames = []
for f in task_files:
fname = self.tmp_path / osp.basename(f.name)
fname.write_bytes(f.getvalue())
task_filenames.append(fname)
task_filenames = [task_filenames[i] for i in [2, 4, 1, 5, 0, 3]]
task.upload_data(
resources=task_filenames,
params={"sorting_method": "predefined"},
)
assert [f.name for f in task.get_frames_info()] == [f.name for f in task_filenames]
def test_can_create_task_with_remote_data(self):
task = self.client.tasks.create_from_data(
spec={"name": "test_task"},
resource_type=ResourceType.SHARE,
resources=["images/image_1.jpg", "images/image_2.jpg"],
# make sure string fields are transferred correctly;
# see https://github.com/cvat-ai/cvat/issues/4962
data_params={"sorting_method": "lexicographical"},
)
assert task.size == 2
assert task.get_frames_info()[0].name == "images/image_1.jpg"
assert self.stdout.getvalue() == ""
def test_cant_create_task_with_no_data(self):
pbar_out = io.StringIO()
pbar = make_pbar(file=pbar_out)
task_spec = {
"name": f"test {self.user} to create a task with no data",
}
with pytest.raises(BackgroundRequestException) as capture:
self.client.tasks.create_from_data(
spec=task_spec,
resource_type=ResourceType.LOCAL,
resources=[],
pbar=pbar,
)
assert capture.match("No media data found")
assert self.stdout.getvalue() == ""
def test_can_upload_data_to_empty_task(self):
pbar_out = io.StringIO()
pbar = make_pbar(file=pbar_out)
task = self.client.tasks.create(
{
"name": "test task",
}
)
data_params = {
"image_quality": 75,
}
task_files = generate_image_files(7)
for i, f in enumerate(task_files):
fname = self.tmp_path / f.name
fname.write_bytes(f.getvalue())
task_files[i] = fname
task.upload_data(
resources=task_files,
resource_type=ResourceType.LOCAL,
params=data_params,
pbar=pbar,
)
assert task.size == 7
assert "100%" in pbar_out.getvalue().strip("\r").split("\r")[-1]
assert self.stdout.getvalue() == ""
def test_can_retrieve_task(self, fxt_new_task: Task):
task_id = fxt_new_task.id
task = self.client.tasks.retrieve(task_id)
assert task.id == task_id
assert self.stdout.getvalue() == ""
def test_can_list_tasks(self, fxt_new_task: Task):
task_id = fxt_new_task.id
tasks = self.client.tasks.list()
assert any(t.id == task_id for t in tasks)
assert self.stdout.getvalue() == ""
def test_can_list_tasks_with_simple_filter(self, fxt_new_task: Task):
from cvat_sdk.core.proxies import model_proxy
task_name = f"test_task_filter_{fxt_new_task.id}"
fxt_new_task.update(models.PatchedTaskWriteRequest(name=task_name))
with mock.patch.object(
model_proxy,
"get_paginated_collection",
wraps=model_proxy.get_paginated_collection,
) as spy:
tasks = self.client.tasks.list(name=task_name)
# a plain field lookup is forwarded as-is and must not be turned into a complex `filter`
assert spy.call_args.kwargs["name"] == task_name
assert "filter" not in spy.call_args.kwargs
assert [t.id for t in tasks] == [fxt_new_task.id]
assert self.stdout.getvalue() == ""
def test_can_list_tasks_with_json_logic_filter(self, fxt_new_task: Task):
tasks = self.client.tasks.list(filter={"==": [{"var": "id"}, fxt_new_task.id]})
assert [t.id for t in tasks] == [fxt_new_task.id]
assert self.stdout.getvalue() == ""
def test_can_list_tasks_with_json_logic_filter_string(self, fxt_new_task: Task):
filter_value = json.dumps({"==": [{"var": "id"}, fxt_new_task.id]})
tasks = self.client.tasks.list(filter=filter_value)
assert [t.id for t in tasks] == [fxt_new_task.id]
assert self.stdout.getvalue() == ""
def test_can_list_tasks_with_dsl_filter(self, fxt_new_task: Task):
tasks = self.client.tasks.list(filter=F.id == fxt_new_task.id)
assert [t.id for t in tasks] == [fxt_new_task.id]
assert self.stdout.getvalue() == ""
def test_can_list_tasks_with_keyword_lookup(self, fxt_new_task: Task):
tasks = self.client.tasks.list(id__in=[fxt_new_task.id])
assert [t.id for t in tasks] == [fxt_new_task.id]
assert self.stdout.getvalue() == ""
def test_dsl_filter_matches_raw_filter(self, fxt_new_task: Task):
dsl = self.client.tasks.list(filter=F.id == fxt_new_task.id)
raw = self.client.tasks.list(filter={"==": [{"var": "id"}, fxt_new_task.id]})
assert [t.id for t in dsl] == [t.id for t in raw] == [fxt_new_task.id]
assert self.stdout.getvalue() == ""
def test_can_combine_keyword_lookup_and_dsl_filter(self, fxt_new_task: Task):
task_name = f"test_combined_filter_{fxt_new_task.id}"
fxt_new_task.update(models.PatchedTaskWriteRequest(name=task_name))
tasks = self.client.tasks.list(id__in=[fxt_new_task.id], filter=F.name == task_name)
assert [t.id for t in tasks] == [fxt_new_task.id]
assert self.stdout.getvalue() == ""
def test_list_tasks_rejects_unknown_filter_parameter(self):
with pytest.raises(exceptions.ApiTypeError):
self.client.tasks.list(unknown_filter=True)
def test_can_update_task(self, fxt_new_task: Task):
fxt_new_task.update(models.PatchedTaskWriteRequest(name="foo"))
retrieved_task = self.client.tasks.retrieve(fxt_new_task.id)
assert retrieved_task.name == "foo"
assert fxt_new_task.name == retrieved_task.name
assert self.stdout.getvalue() == ""
def test_can_delete_task(self, fxt_new_task: Task):
fxt_new_task.remove()
with pytest.raises(exceptions.NotFoundException):
fxt_new_task.fetch()
assert self.stdout.getvalue() == ""
def test_can_delete_tasks_by_ids(self, fxt_new_task: Task):
task_id = fxt_new_task.id
old_tasks = self.client.tasks.list()
self.client.tasks.remove_by_ids([task_id])
new_tasks = self.client.tasks.list()
assert any(t.id == task_id for t in old_tasks)
assert all(t.id != task_id for t in new_tasks)
assert self.logger_stream.getvalue(), f".*Task ID {task_id} deleted.*"
assert self.stdout.getvalue() == ""
@pytest.mark.parametrize("format_name", ("CVAT for images 1.1",))
@pytest.mark.parametrize("include_images", (True, False))
@parametrize(
"task, location",
[
(fixture_ref("fxt_new_task"), None),
(fixture_ref("fxt_new_task"), Location.LOCAL),
(
pytest.param(
fixture_ref("fxt_new_task"),
Location.CLOUD_STORAGE,
marks=pytest.mark.with_external_services,
)
),
(
pytest.param(
fixture_ref("fxt_new_task_with_target_storage"),
None,
marks=pytest.mark.with_external_services,
)
),
(fixture_ref("fxt_new_task_with_target_storage"), Location.LOCAL),
(
pytest.param(
fixture_ref("fxt_new_task_with_target_storage"),
Location.CLOUD_STORAGE,
marks=pytest.mark.with_external_services,
)
),
],
)
def test_can_export_dataset(
self,
format_name: str,
include_images: bool,
task: Task,
location: Location | None,
request: pytest.FixtureRequest,
cloud_storages: CloudStorageAssets,
):
file_path = self.tmp_path / f"task_{task.id}-{format_name.lower()}.zip"
self._test_can_export_dataset(
task,
format_name=format_name,
file_path=file_path,
include_images=include_images,
location=location,
request=request,
cloud_storages=cloud_storages,
)
def test_can_download_dataset_twice_in_a_row(self, fxt_new_task: Task):
pbar_out = io.StringIO()
pbar = make_pbar(file=pbar_out)
for i in range(2):
path = self.tmp_path / f"dataset-{i}.zip"
fxt_new_task.export_dataset(
format_name="CVAT for images 1.1",
filename=path,
include_images=False,
pbar=pbar,
)
assert self.stdout.getvalue() == ""
assert "100%" in pbar_out.getvalue().strip("\r").split("\r")[-1]
assert path.is_file()
def test_can_download_dataset_with_server_filename(self, fxt_new_task: Task):
pbar_out = io.StringIO()
pbar = make_pbar(file=pbar_out)
output_dir = self.tmp_path
output_path = fxt_new_task.export_dataset(
format_name="CVAT for images 1.1",
filename=output_dir,
include_images=False,
pbar=pbar,
)
assert "100%" in pbar_out.getvalue().strip("\r").split("\r")[-1]
assert output_path.is_relative_to(output_dir)
assert output_path.is_file()
assert self.stdout.getvalue() == ""
def test_can_download_backup(self, fxt_new_task: Task):
pbar_out = io.StringIO()
pbar = make_pbar(file=pbar_out)
task_id = fxt_new_task.id
path = self.tmp_path / f"task_{task_id}-backup.zip"
task = self.client.tasks.retrieve(task_id)
task.download_backup(filename=path, pbar=pbar)
assert "100%" in pbar_out.getvalue().strip("\r").split("\r")[-1]
assert path.is_file()
assert self.stdout.getvalue() == ""
def test_can_download_backup_with_server_filename(self, fxt_new_task: Task):
pbar_out = io.StringIO()
pbar = make_pbar(file=pbar_out)
task_id = fxt_new_task.id
output_dir = self.tmp_path
task = self.client.tasks.retrieve(task_id)
output_path = task.download_backup(filename=output_dir, pbar=pbar)
assert "100%" in pbar_out.getvalue().strip("\r").split("\r")[-1]
assert output_path.is_relative_to(output_dir)
assert output_path.is_file()
assert self.stdout.getvalue() == ""
def test_can_download_preview(self, fxt_new_task: Task):
frame_encoded = fxt_new_task.get_preview()
width, height = Image.open(frame_encoded).size
assert width > 0 and height > 0
assert self.stdout.getvalue() == ""
@pytest.mark.parametrize("quality", ("compressed", "original"))
def test_can_download_frame(self, fxt_new_task: Task, quality: str):
frame_encoded = fxt_new_task.get_frame(0, quality=quality)
width, height = Image.open(frame_encoded).size
assert width > 0 and height > 0
assert self.stdout.getvalue() == ""
@pytest.mark.parametrize("quality", ("compressed", "original"))
@pytest.mark.parametrize("image_extension", (None, "bmp"))
def test_can_download_frames(
self, fxt_new_task: Task, quality: str, image_extension: str | None
):
fxt_new_task.download_frames(
[0],
image_extension=image_extension,
quality=quality,
outdir=self.tmp_path,
filename_pattern="frame-{frame_id}{frame_ext}",
)
if image_extension is not None:
expected_frame_ext = image_extension
else:
if quality == "original":
expected_frame_ext = "png"
else:
expected_frame_ext = "jpg"
assert (self.tmp_path / f"frame-0.{expected_frame_ext}").is_file()
assert self.stdout.getvalue() == ""
@pytest.mark.parametrize("quality", ("compressed", "original"))
def test_can_download_chunk(self, fxt_new_task: Task, quality: str):
chunk_path = self.tmp_path / "chunk.zip"
with open(chunk_path, "wb") as chunk_file:
fxt_new_task.download_chunk(0, chunk_file, quality=quality)
with zipfile.ZipFile(chunk_path, "r") as chunk_zip:
assert chunk_zip.testzip() is None
assert len(chunk_zip.infolist()) == 1
assert self.stdout.getvalue() == ""
@pytest.mark.parametrize("convert", [True, False])
def test_can_upload_annotations(
self, fxt_new_task: Task, fxt_camvid_dataset: Path, convert: bool
):
pbar_out = io.StringIO()
pbar = make_pbar(file=pbar_out)
fxt_new_task.import_annotations(
format_name="CamVid 1.0",
filename=fxt_camvid_dataset,
conv_mask_to_poly=convert,
pbar=pbar,
)
assert "uploaded" in self.logger_stream.getvalue()
assert "100%" in pbar_out.getvalue().strip("\r").split("\r")[-1]
assert self.stdout.getvalue() == ""
imported_annotations = fxt_new_task.get_jobs()[0].get_annotations()
assert all(
s.type.value == "polygon" if convert else "mask" for s in imported_annotations.shapes
)
def _test_can_create_from_backup(self, fxt_new_task: Task, fxt_backup_file: Path):
pbar_out = io.StringIO()
pbar = make_pbar(file=pbar_out)
task = self.client.tasks.create_from_backup(fxt_backup_file, pbar=pbar)
assert task.id
assert task.id != fxt_new_task.id
assert task.size == fxt_new_task.size
assert "imported successfully" in self.logger_stream.getvalue()
assert "100%" in pbar_out.getvalue().strip("\r").split("\r")[-1]
assert self.stdout.getvalue() == ""
def test_can_create_from_backup(self, fxt_new_task: Task, fxt_backup_file: Path):
self._test_can_create_from_backup(fxt_new_task, fxt_backup_file)
def test_can_create_from_backup_in_chunks(
self, monkeypatch: pytest.MonkeyPatch, fxt_new_task: Task, fxt_backup_file: Path
):
monkeypatch.setattr("cvat_sdk.core.uploading.TUS_CHUNK_SIZE", 100)
num_requests = 0
original_request = RESTClientObject.request
def counting_request(self, method, *args, headers, **kwargs):
nonlocal num_requests
if method.upper() == "PATCH" and "Upload-Offset" in headers:
num_requests += 1
return original_request(self, method, *args, headers=headers, **kwargs)
monkeypatch.setattr(RESTClientObject, "request", counting_request)
self._test_can_create_from_backup(fxt_new_task, fxt_backup_file)
# make sure the upload was actually chunked
assert num_requests > 1
def test_can_get_labels(self, fxt_new_task: Task):
expected_labels = {"car", "person"}
received_labels = fxt_new_task.get_labels()
assert {obj.name for obj in received_labels} == expected_labels
assert self.stdout.getvalue() == ""
def test_can_get_jobs(self, fxt_new_task: Task):
jobs = fxt_new_task.get_jobs()
assert len(jobs) != 0
assert self.stdout.getvalue() == ""
def test_can_get_meta(self, fxt_new_task: Task):
meta = fxt_new_task.get_meta()
assert meta.image_quality == 80
assert meta.size == 1
assert not meta.deleted_frames
assert self.stdout.getvalue() == ""
def test_can_get_frame_info(self, fxt_new_task: Task):
meta = fxt_new_task.get_meta()
frames = fxt_new_task.get_frames_info()
assert len(frames) == meta.size
assert frames[0].name == "img.png"
assert frames[0].width == 5
assert frames[0].height == 10
assert self.stdout.getvalue() == ""
def test_can_remove_frames(self, fxt_new_task: Task):
fxt_new_task.remove_frames_by_ids([0])
meta = fxt_new_task.get_meta()
assert meta.deleted_frames == [0]
assert self.stdout.getvalue() == ""
def test_can_get_annotations(self, fxt_task_with_shapes: Task):
anns = fxt_task_with_shapes.get_annotations()
assert len(anns.shapes) == 1
assert anns.shapes[0].type.value == "rectangle"
assert self.stdout.getvalue() == ""
def test_can_set_annotations(self, fxt_new_task: Task):
labels = fxt_new_task.get_labels()
fxt_new_task.set_annotations(
models.LabeledDataRequest(
tags=[models.LabeledImageRequest(frame=0, label_id=labels[0].id)],
)
)
anns = fxt_new_task.get_annotations()
assert len(anns.tags) == 1
assert self.stdout.getvalue() == ""
def test_can_clear_annotations(self, fxt_task_with_shapes: Task):
fxt_task_with_shapes.remove_annotations()
anns = fxt_task_with_shapes.get_annotations()
assert len(anns.tags) == 0
assert len(anns.tracks) == 0
assert len(anns.shapes) == 0
assert self.stdout.getvalue() == ""
def test_can_remove_annotations(self, fxt_new_task: Task):
labels = fxt_new_task.get_labels()
fxt_new_task.set_annotations(
models.LabeledDataRequest(
shapes=[
models.LabeledShapeRequest(
frame=0,
label_id=labels[0].id,
type="rectangle",
points=[1, 1, 2, 2],
),
models.LabeledShapeRequest(
frame=0,
label_id=labels[0].id,
type="rectangle",
points=[2, 2, 3, 3],
),
],
)
)
anns = fxt_new_task.get_annotations()
fxt_new_task.remove_annotations(ids=[anns.shapes[0].id])
anns = fxt_new_task.get_annotations()
assert len(anns.tags) == 0
assert len(anns.tracks) == 0
assert len(anns.shapes) == 1
assert self.stdout.getvalue() == ""
def test_can_update_annotations(self, fxt_task_with_shapes: Task):
labels = fxt_task_with_shapes.get_labels()
fxt_task_with_shapes.update_annotations(
models.PatchedLabeledDataRequest(
shapes=[
models.LabeledShapeRequest(
frame=0,
label_id=labels[0].id,
type="rectangle",
points=[0, 1, 2, 3],
),
],
tracks=[
models.LabeledTrackRequest(
frame=0,
label_id=labels[0].id,
shapes=[
models.TrackedShapeRequest(
frame=0, type="polygon", points=[3, 2, 2, 3, 3, 4]
),
],
)
],
tags=[models.LabeledImageRequest(frame=0, label_id=labels[0].id)],
)
)
anns = fxt_task_with_shapes.get_annotations()
assert len(anns.shapes) == 2
assert len(anns.tracks) == 1
assert len(anns.tags) == 1
assert self.stdout.getvalue() == ""
@pytest.mark.usefixtures("restore_db_per_function")
def test_org_maintainer_can_get_task_resources_without_explicit_org_context(
fxt_org_resource_hierarchy,
):
resources = fxt_org_resource_hierarchy()
with make_sdk_client(resources.maintainer_username) as maintainer_client:
task = maintainer_client.tasks.retrieve(resources.task_id)
jobs = task.get_jobs()
labels = task.get_labels()
assert maintainer_client.organization_slug is None
assert len(jobs) == 1
assert jobs[0].id == resources.job_id
assert {label.name for label in labels} == {"car"}
+71
View File
@@ -0,0 +1,71 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import io
from logging import Logger
from pathlib import Path
import pytest
from cvat_sdk import Client, models
from cvat_sdk.api_client import exceptions
class TestUserUsecases:
@pytest.fixture(autouse=True)
def setup(
self,
tmp_path: Path,
fxt_login: tuple[Client, str],
fxt_logger: tuple[Logger, io.StringIO],
fxt_stdout: io.StringIO,
):
self.tmp_path = tmp_path
logger, self.logger_stream = fxt_logger
self.stdout = fxt_stdout
self.client, self.user = fxt_login
self.client.logger = logger
api_client = self.client.api_client
for k in api_client.configuration.logger:
api_client.configuration.logger[k] = logger
yield
def test_can_retrieve_user(self):
me = self.client.users.retrieve_current_user()
user = self.client.users.retrieve(me.id)
assert user.id == me.id
assert user.username == self.user
assert self.stdout.getvalue() == ""
def test_can_list_users(self):
users = self.client.users.list()
assert self.user in set(u.username for u in users)
assert self.stdout.getvalue() == ""
def test_can_update_user(self):
user = self.client.users.retrieve_current_user()
user.update(models.PatchedUserRequest(first_name="foo", last_name="bar"))
retrieved_user = self.client.users.retrieve(user.id)
assert retrieved_user.first_name == "foo"
assert retrieved_user.last_name == "bar"
assert user.first_name == retrieved_user.first_name
assert user.last_name == retrieved_user.last_name
assert self.stdout.getvalue() == ""
def test_can_remove_user(self):
users = self.client.users.list()
removed_user = next(u for u in users if u.username != self.user)
removed_user.remove()
with pytest.raises(exceptions.NotFoundException):
removed_user.fetch()
assert self.stdout.getvalue() == ""
+166
View File
@@ -0,0 +1,166 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import contextlib
import http.server
import ssl
import textwrap
import threading
from collections.abc import Container, Generator
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
import pytest
import requests
from cvat_sdk.api_client.rest import RESTClientObject
from cvat_sdk.core.helpers import DeferredTqdmProgressReporter
from shared.utils.config import BASE_URL
def make_pbar(file, **kwargs):
return DeferredTqdmProgressReporter({"file": file, "mininterval": 0, **kwargs})
def generate_coco_json(filename: Path, img_info: tuple[Path, int, int]):
image_filename, image_width, image_height = img_info
content = generate_coco_anno(
image_filename.name,
image_width=image_width,
image_height=image_height,
)
with open(filename, "w") as coco:
coco.write(content)
def generate_coco_anno(image_path: str, image_width: int, image_height: int) -> str:
return (
textwrap.dedent("""
{
"categories": [
{
"id": 1,
"name": "car",
"supercategory": ""
},
{
"id": 2,
"name": "person",
"supercategory": ""
}
],
"images": [
{
"coco_url": "",
"date_captured": "",
"flickr_url": "",
"license": 0,
"id": 0,
"file_name": "%(image_path)s",
"height": %(image_height)d,
"width": %(image_width)d
}
],
"annotations": [
{
"category_id": 1,
"id": 1,
"image_id": 0,
"iscrowd": 0,
"segmentation": [
[]
],
"area": 17702.0,
"bbox": [
574.0,
407.0,
167.0,
106.0
]
}
]
}
""")
% {
"image_path": image_path,
"image_height": image_height,
"image_width": image_width,
}
)
def restrict_api_requests(
monkeypatch: pytest.MonkeyPatch, allow_paths: Container[str] = ()
) -> None:
original_request = RESTClientObject.request
def restricted_request(self, method, url, *args, **kwargs):
parsed_url = urlparse(url)
if parsed_url.path in allow_paths:
return original_request(self, method, url, *args, **kwargs)
raise RuntimeError("Disallowed!")
monkeypatch.setattr(RESTClientObject, "request", restricted_request)
@dataclass(frozen=True)
class OrgResourceHierarchy:
owner_username: str
maintainer_username: str
org_slug: str
project_id: int
task_id: int
job_id: int
issue_id: int | None = None
comment_id: int | None = None
@contextlib.contextmanager
def https_reverse_proxy() -> Generator[str, None, None]:
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
cert_dir = Path(__file__).parent
ssl_context.load_cert_chain(cert_dir / "self-signed.crt", cert_dir / "self-signed.key")
with http.server.HTTPServer(("localhost", 0), _ProxyHttpRequestHandler) as proxy_server:
proxy_server.socket = ssl_context.wrap_socket(
proxy_server.socket,
server_side=True,
)
server_thread = threading.Thread(target=proxy_server.serve_forever)
server_thread.start()
try:
yield f"https://localhost:{proxy_server.server_port}"
finally:
proxy_server.shutdown()
server_thread.join()
class _ProxyHttpRequestHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
response = requests.get(**self._shared_request_args())
self._translate_response(response)
def do_POST(self):
body_length = int(self.headers["Content-Length"])
response = requests.post(data=self.rfile.read(body_length), **self._shared_request_args())
self._translate_response(response)
def _shared_request_args(self) -> dict[str, Any]:
headers = {k.lower(): v for k, v in self.headers.items()}
del headers["host"]
return {"url": BASE_URL + self.path, "headers": headers, "timeout": 60, "stream": True}
def _translate_response(self, response: requests.Response) -> None:
self.send_response(response.status_code)
for key, value in response.headers.items():
self.send_header(key, value)
self.end_headers()
# Need to use raw here to prevent requests from handling Content-Encoding.
self.wfile.write(response.raw.read())
@@ -0,0 +1,87 @@
{
"user": {
"admin1": [],
"admin2": [
{
"created_date": "2025-07-17T16:17:35.436000Z",
"expiry_date": null,
"id": 3,
"last_used_date": null,
"name": "test key",
"owner": {
"first_name": "Admin",
"id": 18,
"last_name": "Second",
"url": "http://localhost:8080/api/users/18",
"username": "admin2"
},
"read_only": false,
"updated_date": "2025-07-17T16:17:35.436000Z"
},
{
"created_date": "2025-08-12T10:30:10.206000Z",
"expiry_date": null,
"id": 7,
"last_used_date": null,
"name": "test readonly key",
"owner": {
"first_name": "Admin",
"id": 18,
"last_name": "Second",
"url": "http://localhost:8080/api/users/18",
"username": "admin2"
},
"read_only": true,
"updated_date": "2025-08-12T10:30:10.206000Z"
}
],
"lonely_user": [],
"user1": [
{
"created_date": "2025-07-17T16:19:18.229000Z",
"expiry_date": null,
"id": 4,
"last_used_date": null,
"name": "test readonly key",
"owner": {
"first_name": "User",
"id": 2,
"last_name": "First",
"url": "http://localhost:8080/api/users/2",
"username": "user1"
},
"read_only": true,
"updated_date": "2025-07-17T16:19:18.229000Z"
},
{
"created_date": "2025-07-17T16:19:46.576000Z",
"expiry_date": null,
"id": 5,
"last_used_date": null,
"name": "test readwrite key",
"owner": {
"first_name": "User",
"id": 2,
"last_name": "First",
"url": "http://localhost:8080/api/users/2",
"username": "user1"
},
"read_only": false,
"updated_date": "2025-07-17T16:19:46.576000Z"
}
],
"user10": [],
"user2": [],
"user3": [],
"user4": [],
"user5": [],
"user6": [],
"user7": [],
"user8": [],
"user9": [],
"worker1": [],
"worker2": [],
"worker3": [],
"worker4": []
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,113 @@
{
"count": 5,
"next": null,
"previous": null,
"results": [
{
"created_date": "2026-02-11T16:29:48Z",
"credentials_type": "KEY_SECRET_KEY_PAIR",
"description": "Bucket to be used as backing cloud storage for tasks",
"display_name": "Backing CS",
"id": 5,
"manifests": [],
"organization": null,
"owner": {
"first_name": "Admin",
"id": 1,
"last_name": "First",
"url": "http://localhost:8080/api/users/1",
"username": "admin1"
},
"provider_type": "AWS_S3_BUCKET",
"resource": "backingcs",
"specific_attributes": "endpoint_url=http%3A%2F%2Fminio%3A9000",
"updated_date": "2026-02-11T16:29:48Z"
},
{
"created_date": "2022-06-29T12:56:18.257000Z",
"credentials_type": "KEY_SECRET_KEY_PAIR",
"description": "Bucket with wrong endpoint for testing purposes",
"display_name": "Import/Export bucket with wrong endpoint",
"id": 4,
"manifests": [],
"organization": 2,
"owner": {
"first_name": "Admin",
"id": 1,
"last_name": "First",
"url": "http://localhost:8080/api/users/1",
"username": "admin1"
},
"provider_type": "AWS_S3_BUCKET",
"resource": "importexportbucketwrongendpoint",
"specific_attributes": "endpoint_url=http%3A%2F%2Fminio%3A9777",
"updated_date": "2022-06-29T12:56:18.264000Z"
},
{
"created_date": "2022-06-29T12:56:18.257000Z",
"credentials_type": "KEY_SECRET_KEY_PAIR",
"description": "Bucket for importing and exporting annotations and backups",
"display_name": "Import/Export bucket",
"id": 3,
"manifests": [
"images_with_manifest/manifest.jsonl"
],
"organization": 2,
"owner": {
"first_name": "Admin",
"id": 1,
"last_name": "First",
"url": "http://localhost:8080/api/users/1",
"username": "admin1"
},
"provider_type": "AWS_S3_BUCKET",
"resource": "importexportbucket",
"specific_attributes": "endpoint_url=http%3A%2F%2Fminio%3A9000",
"updated_date": "2022-06-29T12:56:18.264000Z"
},
{
"created_date": "2022-03-17T07:23:59.305000Z",
"credentials_type": "KEY_SECRET_KEY_PAIR",
"description": "",
"display_name": "Bucket 2",
"id": 2,
"manifests": [
"sub/images_with_manifest/manifest.jsonl"
],
"organization": 2,
"owner": {
"first_name": "User",
"id": 11,
"last_name": "Eighth",
"url": "http://localhost:8080/api/users/11",
"username": "user8"
},
"provider_type": "AWS_S3_BUCKET",
"resource": "private",
"specific_attributes": "endpoint_url=http%3A%2F%2Fminio%3A9000",
"updated_date": "2022-07-13T12:46:45.587000Z"
},
{
"created_date": "2022-03-17T07:22:49.519000Z",
"credentials_type": "ANONYMOUS_ACCESS",
"description": "",
"display_name": "Bucket 1",
"id": 1,
"manifests": [
"images_with_manifest/manifest.jsonl"
],
"organization": null,
"owner": {
"first_name": "User",
"id": 2,
"last_name": "First",
"url": "http://localhost:8080/api/users/2",
"username": "user1"
},
"provider_type": "AWS_S3_BUCKET",
"resource": "public",
"specific_attributes": "endpoint_url=http%3A%2F%2Fminio%3A9000",
"updated_date": "2022-03-17T07:22:49.529000Z"
}
]
}
+91
View File
@@ -0,0 +1,91 @@
{
"count": 6,
"next": null,
"previous": null,
"results": [
{
"created_date": "2022-03-16T12:49:29.372000Z",
"id": 6,
"issue": 5,
"message": "Wrong position",
"owner": {
"first_name": "User",
"id": 20,
"last_name": "Sixth",
"url": "http://localhost:8080/api/users/20",
"username": "user6"
},
"updated_date": "2022-03-16T12:49:29.372000Z"
},
{
"created_date": "2022-03-16T12:40:00.767000Z",
"id": 5,
"issue": 4,
"message": "Issue with empty frame",
"owner": {
"first_name": "User",
"id": 2,
"last_name": "First",
"url": "http://localhost:8080/api/users/2",
"username": "user1"
},
"updated_date": "2022-03-16T12:40:00.767000Z"
},
{
"created_date": "2022-03-16T11:08:18.370000Z",
"id": 4,
"issue": 3,
"message": "Another one issue",
"owner": {
"first_name": "User",
"id": 11,
"last_name": "Eighth",
"url": "http://localhost:8080/api/users/11",
"username": "user8"
},
"updated_date": "2022-03-16T11:08:18.370000Z"
},
{
"created_date": "2022-03-16T11:07:22.173000Z",
"id": 3,
"issue": 2,
"message": "Something should be here",
"owner": {
"first_name": "User",
"id": 11,
"last_name": "Eighth",
"url": "http://localhost:8080/api/users/11",
"username": "user8"
},
"updated_date": "2022-03-16T11:07:22.173000Z"
},
{
"created_date": "2022-03-16T11:04:49.821000Z",
"id": 2,
"issue": 1,
"message": "Just to suffer?",
"owner": {
"first_name": "User",
"id": 2,
"last_name": "First",
"url": "http://localhost:8080/api/users/2",
"username": "user1"
},
"updated_date": "2022-03-16T11:04:49.821000Z"
},
{
"created_date": "2022-03-16T11:04:39.447000Z",
"id": 1,
"issue": 1,
"message": "Why are we still here?",
"owner": {
"first_name": "User",
"id": 2,
"last_name": "First",
"url": "http://localhost:8080/api/users/2",
"username": "user1"
},
"updated_date": "2022-03-16T11:04:39.447000Z"
}
]
}
@@ -0,0 +1,17 @@
{
"count": 2,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"iou_threshold": 0.4,
"task_id": 30
},
{
"id": 2,
"iou_threshold": 0.4,
"task_id": 31
}
]
}
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,11 @@
SELECT :'from' = 'cvat' AS fromcvat \gset
\if :fromcvat
SELECT pg_terminate_backend(pg_stat_activity.pid)
FROM pg_stat_activity
WHERE pg_stat_activity.datname = 'cvat' AND pid <> pg_backend_pid();
\endif
DROP DATABASE IF EXISTS :to WITH (FORCE);
CREATE DATABASE :to WITH TEMPLATE :from;
+345
View File
@@ -0,0 +1,345 @@
{
"count": 13,
"next": null,
"previous": null,
"results": [
{
"accepted": true,
"created_date": "2023-09-15T07:53:52.116000Z",
"expired": true,
"key": "q8GWTPiR1Vz9DDO6MQo1B6pUBzW9GjDb6AUQPziAV62jD7OpCLZji0GS66C48wRX",
"organization": 1,
"organization_info": {
"id": 1,
"slug": "org1"
},
"owner": {
"first_name": "User",
"id": 2,
"last_name": "First",
"url": "http://localhost:8080/api/users/2",
"username": "user1"
},
"role": "maintainer",
"user": {
"first_name": "User",
"id": 11,
"last_name": "Eighth",
"url": "http://localhost:8080/api/users/11",
"username": "user8"
}
},
{
"accepted": true,
"created_date": "2023-09-15T07:53:52.115000Z",
"expired": true,
"key": "d2Zaawf81uImG1nmWA0Va0Bv5EPERt1edJDTgTgMZiefZ2QmC1IdPld9LIPnkiWR",
"organization": 1,
"organization_info": {
"id": 1,
"slug": "org1"
},
"owner": {
"first_name": "User",
"id": 2,
"last_name": "First",
"url": "http://localhost:8080/api/users/2",
"username": "user1"
},
"role": "supervisor",
"user": {
"first_name": "User",
"id": 5,
"last_name": "Fourth",
"url": "http://localhost:8080/api/users/5",
"username": "user4"
}
},
{
"accepted": true,
"created_date": "2022-09-28T13:11:37.839000Z",
"expired": true,
"key": "hIH9RB3QqZLFwdUDmufaSPc2H8uS5cNjcG6pk8gfAIQ4jg6nJZZWDIQHMN1gFMk9",
"organization": 1,
"organization_info": {
"id": 1,
"slug": "org1"
},
"owner": {
"first_name": "Admin",
"id": 1,
"last_name": "First",
"url": "http://localhost:8080/api/users/1",
"username": "admin1"
},
"role": "supervisor",
"user": {
"first_name": "User",
"id": 19,
"last_name": "Fifth",
"url": "http://localhost:8080/api/users/19",
"username": "user5"
}
},
{
"accepted": true,
"created_date": "2022-02-24T21:29:21.978000Z",
"expired": true,
"key": "Fi3WRUhFxTWpMiVpdwNR2CGyhgcIXSCUYgPCugPq72QUOgHz9NSMOGiKS3PfJ7Ql",
"organization": 2,
"organization_info": {
"id": 2,
"slug": "org2"
},
"owner": {
"first_name": "Admin",
"id": 1,
"last_name": "First",
"url": "http://localhost:8080/api/users/1",
"username": "admin1"
},
"role": "worker",
"user": {
"first_name": "User",
"id": 19,
"last_name": "Fifth",
"url": "http://localhost:8080/api/users/19",
"username": "user5"
}
},
{
"accepted": true,
"created_date": "2022-01-19T13:54:42.015000Z",
"expired": true,
"key": "BrwoDmMNQQ1v9WXOukp9DwQVuqB3RDPjpUECCEq6QcAuG0Pi8k1IYtQ9uz9jg0Bv",
"organization": 2,
"organization_info": {
"id": 2,
"slug": "org2"
},
"owner": {
"first_name": "User",
"id": 10,
"last_name": "Seventh",
"url": "http://localhost:8080/api/users/10",
"username": "user7"
},
"role": "maintainer",
"user": {
"first_name": "User",
"id": 5,
"last_name": "Fourth",
"url": "http://localhost:8080/api/users/5",
"username": "user4"
}
},
{
"accepted": true,
"created_date": "2022-01-19T13:54:42.005000Z",
"expired": true,
"key": "5FjIXya6fTGvlRpauFvi2QN1wDOqo1V9REB5rJinDR8FZO9gr0qmtWpghsCte8Y1",
"organization": 2,
"organization_info": {
"id": 2,
"slug": "org2"
},
"owner": {
"first_name": "User",
"id": 10,
"last_name": "Seventh",
"url": "http://localhost:8080/api/users/10",
"username": "user7"
},
"role": "supervisor",
"user": {
"first_name": "User",
"id": 4,
"last_name": "Third",
"url": "http://localhost:8080/api/users/4",
"username": "user3"
}
},
{
"accepted": true,
"created_date": "2021-12-14T19:55:13.745000Z",
"expired": true,
"key": "h43G28di7vfs4Jv5VrKZ26xvGAfm6Yc2FFv14z9EKhiuIEDQ22pEnzmSCab8MnK1",
"organization": 2,
"organization_info": {
"id": 2,
"slug": "org2"
},
"owner": {
"first_name": "User",
"id": 10,
"last_name": "Seventh",
"url": "http://localhost:8080/api/users/10",
"username": "user7"
},
"role": "supervisor",
"user": {
"first_name": "User",
"id": 3,
"last_name": "Second",
"url": "http://localhost:8080/api/users/3",
"username": "user2"
}
},
{
"accepted": true,
"created_date": "2021-12-14T19:54:56.431000Z",
"expired": true,
"key": "mFpVV2Yh39uUdU8IpigSxvuPegqi8sjxFi6P9Jdy6fBE8Ky9Juzi1KjeGDQsizSS",
"organization": 2,
"organization_info": {
"id": 2,
"slug": "org2"
},
"owner": {
"first_name": "User",
"id": 10,
"last_name": "Seventh",
"url": "http://localhost:8080/api/users/10",
"username": "user7"
},
"role": "worker",
"user": {
"first_name": "Worker",
"id": 8,
"last_name": "Third",
"url": "http://localhost:8080/api/users/8",
"username": "worker3"
}
},
{
"accepted": true,
"created_date": "2021-12-14T19:54:46.172000Z",
"expired": true,
"key": "62HplmGPJuzpTXSyzPWiAlREkq8smCjK30GdtYze3q03J9X5ghQe3oMhlAyQ0WBH",
"organization": 2,
"organization_info": {
"id": 2,
"slug": "org2"
},
"owner": {
"first_name": "User",
"id": 10,
"last_name": "Seventh",
"url": "http://localhost:8080/api/users/10",
"username": "user7"
},
"role": "worker",
"user": {
"first_name": "Worker",
"id": 7,
"last_name": "Second",
"url": "http://localhost:8080/api/users/7",
"username": "worker2"
}
},
{
"accepted": true,
"created_date": "2021-12-14T19:54:33.591000Z",
"expired": true,
"key": "Y1I4FFU27WRqq2rWQLtKjDztMqpvqW7gJgg7q73F7oE4H5kukvXugWjiTLHclPDu",
"organization": 2,
"organization_info": {
"id": 2,
"slug": "org2"
},
"owner": {
"first_name": "User",
"id": 10,
"last_name": "Seventh",
"url": "http://localhost:8080/api/users/10",
"username": "user7"
},
"role": "maintainer",
"user": {
"first_name": "User",
"id": 11,
"last_name": "Eighth",
"url": "http://localhost:8080/api/users/11",
"username": "user8"
}
},
{
"accepted": true,
"created_date": "2021-12-14T18:48:46.579000Z",
"expired": true,
"key": "cbmm587Z05WQUYvesIZUCtbTl7CEL4thv1Au6Nr51psflITn9X6BsvNFXcNEkoYn",
"organization": 1,
"organization_info": {
"id": 1,
"slug": "org1"
},
"owner": {
"first_name": "User",
"id": 2,
"last_name": "First",
"url": "http://localhost:8080/api/users/2",
"username": "user1"
},
"role": "maintainer",
"user": {
"first_name": "User",
"id": 10,
"last_name": "Seventh",
"url": "http://localhost:8080/api/users/10",
"username": "user7"
}
},
{
"accepted": true,
"created_date": "2021-12-14T18:47:49.322000Z",
"expired": true,
"key": "aViZkw9TaieLoZaswEnkMy8tTet1yYDRof3eKZDtZaHf1BItgCNNM6y6fnjrkrej",
"organization": 1,
"organization_info": {
"id": 1,
"slug": "org1"
},
"owner": {
"first_name": "User",
"id": 2,
"last_name": "First",
"url": "http://localhost:8080/api/users/2",
"username": "user1"
},
"role": "worker",
"user": {
"first_name": "Worker",
"id": 7,
"last_name": "Second",
"url": "http://localhost:8080/api/users/7",
"username": "worker2"
}
},
{
"accepted": true,
"created_date": "2021-12-14T18:47:39.935000Z",
"expired": true,
"key": "Lzyzgo161I7Fej1vC5RXPdyUgCBfbuxsEEhHYeYOJqvbeJe5clPDnqCm7pKOC9tr",
"organization": 1,
"organization_info": {
"id": 1,
"slug": "org1"
},
"owner": {
"first_name": "User",
"id": 2,
"last_name": "First",
"url": "http://localhost:8080/api/users/2",
"username": "user1"
},
"role": "worker",
"user": {
"first_name": "Worker",
"id": 6,
"last_name": "First",
"url": "http://localhost:8080/api/users/6",
"username": "worker1"
}
}
]
}
+153
View File
@@ -0,0 +1,153 @@
{
"count": 5,
"next": null,
"previous": null,
"results": [
{
"assignee": null,
"comments": {
"count": 1,
"url": "http://localhost:8080/api/comments?issue_id=5"
},
"created_date": "2022-03-16T12:49:29.369000Z",
"frame": 0,
"id": 5,
"job": 11,
"owner": {
"first_name": "User",
"id": 20,
"last_name": "Sixth",
"url": "http://localhost:8080/api/users/20",
"username": "user6"
},
"position": [
65.6189987163034,
100.96585365853753,
142.12734274711147,
362.6243902439037
],
"resolved": false,
"updated_date": "2022-03-16T12:49:29.369000Z"
},
{
"assignee": {
"first_name": "User",
"id": 2,
"last_name": "First",
"url": "http://localhost:8080/api/users/2",
"username": "user1"
},
"comments": {
"count": 1,
"url": "http://localhost:8080/api/comments?issue_id=4"
},
"created_date": "2022-03-16T12:40:00.764000Z",
"frame": 5,
"id": 4,
"job": 10,
"owner": {
"first_name": "User",
"id": 2,
"last_name": "First",
"url": "http://localhost:8080/api/users/2",
"username": "user1"
},
"position": [
295.36328125,
243.6044921875,
932.23046875,
561.4921875
],
"resolved": false,
"updated_date": "2022-03-16T12:40:00.764000Z"
},
{
"assignee": null,
"comments": {
"count": 1,
"url": "http://localhost:8080/api/comments?issue_id=3"
},
"created_date": "2022-03-16T11:08:18.367000Z",
"frame": 5,
"id": 3,
"job": 16,
"owner": {
"first_name": "User",
"id": 11,
"last_name": "Eighth",
"url": "http://localhost:8080/api/users/11",
"username": "user8"
},
"position": [
108.1845703125,
235.0,
720.0087890625,
703.3505859375
],
"resolved": false,
"updated_date": "2022-03-16T11:08:18.367000Z"
},
{
"assignee": null,
"comments": {
"count": 1,
"url": "http://localhost:8080/api/comments?issue_id=2"
},
"created_date": "2022-03-16T11:07:22.170000Z",
"frame": 0,
"id": 2,
"job": 9,
"owner": {
"first_name": "User",
"id": 11,
"last_name": "Eighth",
"url": "http://localhost:8080/api/users/11",
"username": "user8"
},
"position": [
98.48046875,
696.72265625,
326.1220703125,
841.5859375
],
"resolved": false,
"updated_date": "2022-03-16T11:07:22.170000Z"
},
{
"assignee": null,
"comments": {
"count": 2,
"url": "http://localhost:8080/api/comments?issue_id=1"
},
"created_date": "2022-03-16T11:04:39.444000Z",
"frame": 0,
"id": 1,
"job": 7,
"owner": {
"first_name": "User",
"id": 2,
"last_name": "First",
"url": "http://localhost:8080/api/users/2",
"username": "user1"
},
"position": [
244.58581235698148,
319.63386727689067,
326.9656750572103,
192.76887871853796,
543.6247139588122,
175.4691075514893,
835.2494279176244,
360.0000000000018,
609.5286041189956,
586.544622425632,
364.0361328125,
528.87890625,
244.58581235698148,
319.63386727689067
],
"resolved": true,
"updated_date": "2022-03-16T11:04:39.444000Z"
}
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+247
View File
@@ -0,0 +1,247 @@
{
"count": 16,
"next": null,
"previous": null,
"results": [
{
"id": 16,
"invitation": null,
"is_active": true,
"joined_date": "2021-12-14T18:45:40.172000Z",
"organization": 3,
"role": "owner",
"user": {
"first_name": "User",
"id": 2,
"last_name": "First",
"url": "http://localhost:8080/api/users/2",
"username": "user1"
}
},
{
"id": 15,
"invitation": "q8GWTPiR1Vz9DDO6MQo1B6pUBzW9GjDb6AUQPziAV62jD7OpCLZji0GS66C48wRX",
"is_active": true,
"joined_date": "2023-09-15T07:53:52.116000Z",
"organization": 1,
"role": "maintainer",
"user": {
"first_name": "User",
"id": 11,
"last_name": "Eighth",
"url": "http://localhost:8080/api/users/11",
"username": "user8"
}
},
{
"id": 14,
"invitation": "d2Zaawf81uImG1nmWA0Va0Bv5EPERt1edJDTgTgMZiefZ2QmC1IdPld9LIPnkiWR",
"is_active": true,
"joined_date": "2023-09-15T07:53:52.115000Z",
"organization": 1,
"role": "supervisor",
"user": {
"first_name": "User",
"id": 5,
"last_name": "Fourth",
"url": "http://localhost:8080/api/users/5",
"username": "user4"
}
},
{
"id": 13,
"invitation": "hIH9RB3QqZLFwdUDmufaSPc2H8uS5cNjcG6pk8gfAIQ4jg6nJZZWDIQHMN1gFMk9",
"is_active": true,
"joined_date": "2022-09-28T13:11:37.839000Z",
"organization": 1,
"role": "supervisor",
"user": {
"first_name": "User",
"id": 19,
"last_name": "Fifth",
"url": "http://localhost:8080/api/users/19",
"username": "user5"
}
},
{
"id": 12,
"invitation": "Fi3WRUhFxTWpMiVpdwNR2CGyhgcIXSCUYgPCugPq72QUOgHz9NSMOGiKS3PfJ7Ql",
"is_active": true,
"joined_date": "2022-02-24T21:29:21.978000Z",
"organization": 2,
"role": "worker",
"user": {
"first_name": "User",
"id": 19,
"last_name": "Fifth",
"url": "http://localhost:8080/api/users/19",
"username": "user5"
}
},
{
"id": 11,
"invitation": "BrwoDmMNQQ1v9WXOukp9DwQVuqB3RDPjpUECCEq6QcAuG0Pi8k1IYtQ9uz9jg0Bv",
"is_active": true,
"joined_date": "2022-01-19T13:54:42.015000Z",
"organization": 2,
"role": "maintainer",
"user": {
"first_name": "User",
"id": 5,
"last_name": "Fourth",
"url": "http://localhost:8080/api/users/5",
"username": "user4"
}
},
{
"id": 10,
"invitation": "5FjIXya6fTGvlRpauFvi2QN1wDOqo1V9REB5rJinDR8FZO9gr0qmtWpghsCte8Y1",
"is_active": true,
"joined_date": "2022-01-19T13:54:42.005000Z",
"organization": 2,
"role": "supervisor",
"user": {
"first_name": "User",
"id": 4,
"last_name": "Third",
"url": "http://localhost:8080/api/users/4",
"username": "user3"
}
},
{
"id": 9,
"invitation": "h43G28di7vfs4Jv5VrKZ26xvGAfm6Yc2FFv14z9EKhiuIEDQ22pEnzmSCab8MnK1",
"is_active": true,
"joined_date": "2021-12-14T19:55:13.745000Z",
"organization": 2,
"role": "supervisor",
"user": {
"first_name": "User",
"id": 3,
"last_name": "Second",
"url": "http://localhost:8080/api/users/3",
"username": "user2"
}
},
{
"id": 8,
"invitation": "mFpVV2Yh39uUdU8IpigSxvuPegqi8sjxFi6P9Jdy6fBE8Ky9Juzi1KjeGDQsizSS",
"is_active": true,
"joined_date": "2021-12-14T19:54:56.431000Z",
"organization": 2,
"role": "worker",
"user": {
"first_name": "Worker",
"id": 8,
"last_name": "Third",
"url": "http://localhost:8080/api/users/8",
"username": "worker3"
}
},
{
"id": 7,
"invitation": "62HplmGPJuzpTXSyzPWiAlREkq8smCjK30GdtYze3q03J9X5ghQe3oMhlAyQ0WBH",
"is_active": true,
"joined_date": "2021-12-14T19:54:46.172000Z",
"organization": 2,
"role": "worker",
"user": {
"first_name": "Worker",
"id": 7,
"last_name": "Second",
"url": "http://localhost:8080/api/users/7",
"username": "worker2"
}
},
{
"id": 6,
"invitation": "Y1I4FFU27WRqq2rWQLtKjDztMqpvqW7gJgg7q73F7oE4H5kukvXugWjiTLHclPDu",
"is_active": true,
"joined_date": "2021-12-14T19:54:33.591000Z",
"organization": 2,
"role": "maintainer",
"user": {
"first_name": "User",
"id": 11,
"last_name": "Eighth",
"url": "http://localhost:8080/api/users/11",
"username": "user8"
}
},
{
"id": 5,
"invitation": null,
"is_active": true,
"joined_date": "2021-12-14T19:51:38.667000Z",
"organization": 2,
"role": "owner",
"user": {
"first_name": "User",
"id": 10,
"last_name": "Seventh",
"url": "http://localhost:8080/api/users/10",
"username": "user7"
}
},
{
"id": 4,
"invitation": "cbmm587Z05WQUYvesIZUCtbTl7CEL4thv1Au6Nr51psflITn9X6BsvNFXcNEkoYn",
"is_active": true,
"joined_date": "2021-12-14T18:48:46.579000Z",
"organization": 1,
"role": "maintainer",
"user": {
"first_name": "User",
"id": 10,
"last_name": "Seventh",
"url": "http://localhost:8080/api/users/10",
"username": "user7"
}
},
{
"id": 3,
"invitation": "aViZkw9TaieLoZaswEnkMy8tTet1yYDRof3eKZDtZaHf1BItgCNNM6y6fnjrkrej",
"is_active": true,
"joined_date": "2021-12-14T18:47:49.322000Z",
"organization": 1,
"role": "worker",
"user": {
"first_name": "Worker",
"id": 7,
"last_name": "Second",
"url": "http://localhost:8080/api/users/7",
"username": "worker2"
}
},
{
"id": 2,
"invitation": "Lzyzgo161I7Fej1vC5RXPdyUgCBfbuxsEEhHYeYOJqvbeJe5clPDnqCm7pKOC9tr",
"is_active": true,
"joined_date": "2021-12-14T18:47:39.935000Z",
"organization": 1,
"role": "worker",
"user": {
"first_name": "Worker",
"id": 6,
"last_name": "First",
"url": "http://localhost:8080/api/users/6",
"username": "worker1"
}
},
{
"id": 1,
"invitation": null,
"is_active": true,
"joined_date": "2021-12-14T18:45:40.172000Z",
"organization": 1,
"role": "owner",
"user": {
"first_name": "User",
"id": 2,
"last_name": "First",
"url": "http://localhost:8080/api/users/2",
"username": "user1"
}
}
]
}
@@ -0,0 +1,61 @@
{
"count": 3,
"next": null,
"previous": null,
"results": [
{
"contact": {
"email": "org3@cvat.org"
},
"created_date": "2021-12-14T19:51:38.667000Z",
"description": "",
"id": 3,
"name": "Organization #3",
"owner": {
"first_name": "User",
"id": 2,
"last_name": "First",
"url": "http://localhost:8080/api/users/2",
"username": "user1"
},
"slug": "org3",
"updated_date": "2021-12-14T19:51:38.667000Z"
},
{
"contact": {
"email": "org2@cvat.org"
},
"created_date": "2021-12-14T19:51:38.667000Z",
"description": "",
"id": 2,
"name": "Organization #2",
"owner": {
"first_name": "User",
"id": 10,
"last_name": "Seventh",
"url": "http://localhost:8080/api/users/10",
"username": "user7"
},
"slug": "org2",
"updated_date": "2021-12-14T19:51:38.667000Z"
},
{
"contact": {
"email": "org1@cvat.org"
},
"created_date": "2021-12-14T18:45:40.172000Z",
"description": "",
"id": 1,
"name": "organization #1",
"owner": {
"first_name": "User",
"id": 2,
"last_name": "First",
"url": "http://localhost:8080/api/users/2",
"username": "user1"
},
"slug": "org1",
"updated_date": "2021-12-14T18:45:40.172000Z"
}
]
}
+708
View File
@@ -0,0 +1,708 @@
{
"count": 17,
"next": null,
"previous": null,
"results": [
{
"assignee": null,
"assignee_updated_date": null,
"bug_tracker": "",
"created_date": "2025-11-13T11:32:07.581000Z",
"dimension": "2d",
"guide_id": null,
"id": 18,
"labels": {
"url": "http://localhost:8080/api/labels?project_id=18"
},
"name": "project with tags",
"organization": null,
"organization_id": null,
"owner": {
"first_name": "Admin",
"id": 1,
"last_name": "First",
"url": "http://localhost:8080/api/users/1",
"username": "admin1"
},
"source_storage": {
"cloud_storage_id": null,
"id": 57,
"location": "local"
},
"status": "annotation",
"target_storage": {
"cloud_storage_id": null,
"id": 58,
"location": "local"
},
"task_subsets": [],
"tasks": {
"count": 1,
"url": "http://localhost:8080/api/tasks?project_id=18"
},
"updated_date": "2025-11-13T11:32:48.144000Z",
"url": "http://localhost:8080/api/projects/18"
},
{
"assignee": null,
"assignee_updated_date": null,
"bug_tracker": "",
"created_date": "2025-05-05T15:35:00.036000Z",
"dimension": "2d",
"guide_id": null,
"id": 17,
"labels": {
"url": "http://localhost:8080/api/labels?project_id=17"
},
"name": "project 4",
"organization": 2,
"organization_id": 2,
"owner": {
"first_name": "User",
"id": 3,
"last_name": "Second",
"url": "http://localhost:8080/api/users/3",
"username": "user2"
},
"source_storage": null,
"status": "annotation",
"target_storage": null,
"task_subsets": [],
"tasks": {
"count": 2,
"url": "http://localhost:8080/api/tasks?project_id=17"
},
"updated_date": "2025-05-05T15:35:00.542000Z",
"url": "http://localhost:8080/api/projects/17"
},
{
"assignee": {
"first_name": "User",
"id": 10,
"last_name": "Seventh",
"url": "http://localhost:8080/api/users/10",
"username": "user7"
},
"assignee_updated_date": "2024-09-23T08:09:45.461000Z",
"bug_tracker": "",
"created_date": "2024-09-23T08:09:42.827000Z",
"dimension": null,
"guide_id": null,
"id": 16,
"labels": {
"url": "http://localhost:8080/api/labels?project_id=16"
},
"name": "project assigned to org owner",
"organization": 2,
"organization_id": 2,
"owner": {
"first_name": "Admin",
"id": 1,
"last_name": "First",
"url": "http://localhost:8080/api/users/1",
"username": "admin1"
},
"source_storage": {
"cloud_storage_id": null,
"id": 43,
"location": "local"
},
"status": "annotation",
"target_storage": {
"cloud_storage_id": null,
"id": 44,
"location": "local"
},
"task_subsets": [],
"tasks": {
"count": 0,
"url": "http://localhost:8080/api/tasks?project_id=16"
},
"updated_date": "2024-09-23T08:09:45.474000Z",
"url": "http://localhost:8080/api/projects/16"
},
{
"assignee": {
"first_name": "User",
"id": 5,
"last_name": "Fourth",
"url": "http://localhost:8080/api/users/5",
"username": "user4"
},
"assignee_updated_date": "2024-09-22T20:40:23.452000Z",
"bug_tracker": "",
"created_date": "2024-09-22T20:40:15.423000Z",
"dimension": null,
"guide_id": null,
"id": 15,
"labels": {
"url": "http://localhost:8080/api/labels?project_id=15"
},
"name": "project assigned to user4",
"organization": 2,
"organization_id": 2,
"owner": {
"first_name": "Admin",
"id": 1,
"last_name": "First",
"url": "http://localhost:8080/api/users/1",
"username": "admin1"
},
"source_storage": {
"cloud_storage_id": null,
"id": 41,
"location": "local"
},
"status": "annotation",
"target_storage": {
"cloud_storage_id": null,
"id": 42,
"location": "local"
},
"task_subsets": [],
"tasks": {
"count": 0,
"url": "http://localhost:8080/api/tasks?project_id=15"
},
"updated_date": "2024-09-22T20:40:23.462000Z",
"url": "http://localhost:8080/api/projects/15"
},
{
"assignee": null,
"assignee_updated_date": null,
"bug_tracker": "",
"created_date": "2024-07-15T15:29:04.426000Z",
"dimension": "2d",
"guide_id": null,
"id": 14,
"labels": {
"url": "http://localhost:8080/api/labels?project_id=14"
},
"name": "project with subsets",
"organization": null,
"organization_id": null,
"owner": {
"first_name": "Admin",
"id": 1,
"last_name": "First",
"url": "http://localhost:8080/api/users/1",
"username": "admin1"
},
"source_storage": {
"cloud_storage_id": null,
"id": 35,
"location": "local"
},
"status": "annotation",
"target_storage": {
"cloud_storage_id": null,
"id": 36,
"location": "local"
},
"task_subsets": [
"Train",
"Validation"
],
"tasks": {
"count": 2,
"url": "http://localhost:8080/api/tasks?project_id=14"
},
"updated_date": "2024-07-15T15:34:53.175000Z",
"url": "http://localhost:8080/api/projects/14"
},
{
"assignee": null,
"assignee_updated_date": null,
"bug_tracker": "",
"created_date": "2023-03-10T11:58:04.216000Z",
"dimension": null,
"guide_id": null,
"id": 13,
"labels": {
"url": "http://localhost:8080/api/labels?project_id=13"
},
"name": "project with attributes 2",
"organization": null,
"organization_id": null,
"owner": {
"first_name": "Admin",
"id": 1,
"last_name": "First",
"url": "http://localhost:8080/api/users/1",
"username": "admin1"
},
"source_storage": {
"cloud_storage_id": null,
"id": 31,
"location": "local"
},
"status": "annotation",
"target_storage": {
"cloud_storage_id": null,
"id": 32,
"location": "local"
},
"task_subsets": [],
"tasks": {
"count": 0,
"url": "http://localhost:8080/api/tasks?project_id=13"
},
"updated_date": "2023-03-10T11:58:04.216000Z",
"url": "http://localhost:8080/api/projects/13"
},
{
"assignee": null,
"assignee_updated_date": null,
"bug_tracker": "",
"created_date": "2023-03-10T11:57:14.944000Z",
"dimension": "2d",
"guide_id": null,
"id": 12,
"labels": {
"url": "http://localhost:8080/api/labels?project_id=12"
},
"name": "project with attributes",
"organization": null,
"organization_id": null,
"owner": {
"first_name": "Admin",
"id": 1,
"last_name": "First",
"url": "http://localhost:8080/api/users/1",
"username": "admin1"
},
"source_storage": {
"cloud_storage_id": null,
"id": 27,
"location": "local"
},
"status": "annotation",
"target_storage": {
"cloud_storage_id": null,
"id": 28,
"location": "local"
},
"task_subsets": [],
"tasks": {
"count": 1,
"url": "http://localhost:8080/api/tasks?project_id=12"
},
"updated_date": "2023-03-10T11:57:48.747000Z",
"url": "http://localhost:8080/api/projects/12"
},
{
"assignee": null,
"assignee_updated_date": null,
"bug_tracker": "",
"created_date": "2023-03-01T15:36:11.840000Z",
"dimension": "2d",
"guide_id": null,
"id": 11,
"labels": {
"url": "http://localhost:8080/api/labels?project_id=11"
},
"name": "project7",
"organization": 2,
"organization_id": 2,
"owner": {
"first_name": "Admin",
"id": 1,
"last_name": "First",
"url": "http://localhost:8080/api/users/1",
"username": "admin1"
},
"source_storage": {
"cloud_storage_id": null,
"id": 21,
"location": "local"
},
"status": "annotation",
"target_storage": {
"cloud_storage_id": null,
"id": 22,
"location": "local"
},
"task_subsets": [],
"tasks": {
"count": 1,
"url": "http://localhost:8080/api/tasks?project_id=11"
},
"updated_date": "2023-03-01T15:36:37.812000Z",
"url": "http://localhost:8080/api/projects/11"
},
{
"assignee": {
"first_name": "Worker",
"id": 8,
"last_name": "Third",
"url": "http://localhost:8080/api/users/8",
"username": "worker3"
},
"assignee_updated_date": null,
"bug_tracker": "",
"created_date": "2023-02-10T11:42:43.192000Z",
"dimension": "2d",
"guide_id": null,
"id": 10,
"labels": {
"url": "http://localhost:8080/api/labels?project_id=10"
},
"name": "project 6",
"organization": 2,
"organization_id": 2,
"owner": {
"first_name": "User",
"id": 4,
"last_name": "Third",
"url": "http://localhost:8080/api/users/4",
"username": "user3"
},
"source_storage": {
"cloud_storage_id": null,
"id": 19,
"location": "local"
},
"status": "annotation",
"target_storage": {
"cloud_storage_id": null,
"id": 20,
"location": "local"
},
"task_subsets": [],
"tasks": {
"count": 1,
"url": "http://localhost:8080/api/tasks?project_id=10"
},
"updated_date": "2024-09-23T21:42:22.688000Z",
"url": "http://localhost:8080/api/projects/10"
},
{
"assignee": null,
"assignee_updated_date": null,
"bug_tracker": "",
"created_date": "2022-12-01T12:52:42.454000Z",
"dimension": "2d",
"guide_id": null,
"id": 8,
"labels": {
"url": "http://localhost:8080/api/labels?project_id=8"
},
"name": "project with video data",
"organization": null,
"organization_id": null,
"owner": {
"first_name": "Admin",
"id": 1,
"last_name": "First",
"url": "http://localhost:8080/api/users/1",
"username": "admin1"
},
"source_storage": {
"cloud_storage_id": null,
"id": 13,
"location": "local"
},
"status": "annotation",
"target_storage": {
"cloud_storage_id": null,
"id": 14,
"location": "local"
},
"task_subsets": [],
"tasks": {
"count": 1,
"url": "http://localhost:8080/api/tasks?project_id=8"
},
"updated_date": "2022-12-01T12:53:34.917000Z",
"url": "http://localhost:8080/api/projects/8"
},
{
"assignee": {
"first_name": "Worker",
"id": 9,
"last_name": "Fourth",
"url": "http://localhost:8080/api/users/9",
"username": "worker4"
},
"assignee_updated_date": null,
"bug_tracker": "",
"created_date": "2022-09-28T12:26:25.296000Z",
"dimension": null,
"guide_id": null,
"id": 7,
"labels": {
"url": "http://localhost:8080/api/labels?project_id=7"
},
"name": "admin1_project",
"organization": null,
"organization_id": null,
"owner": {
"first_name": "Admin",
"id": 1,
"last_name": "First",
"url": "http://localhost:8080/api/users/1",
"username": "admin1"
},
"source_storage": {
"cloud_storage_id": null,
"id": 11,
"location": "local"
},
"status": "annotation",
"target_storage": {
"cloud_storage_id": null,
"id": 12,
"location": "local"
},
"task_subsets": [],
"tasks": {
"count": 0,
"url": "http://localhost:8080/api/tasks?project_id=7"
},
"updated_date": "2022-09-28T12:26:29.285000Z",
"url": "http://localhost:8080/api/projects/7"
},
{
"assignee": {
"first_name": "User",
"id": 13,
"last_name": "Tenth",
"url": "http://localhost:8080/api/users/13",
"username": "user10"
},
"assignee_updated_date": null,
"bug_tracker": "",
"created_date": "2022-09-28T12:15:50.768000Z",
"dimension": null,
"guide_id": null,
"id": 6,
"labels": {
"url": "http://localhost:8080/api/labels?project_id=6"
},
"name": "user1_project",
"organization": null,
"organization_id": null,
"owner": {
"first_name": "User",
"id": 2,
"last_name": "First",
"url": "http://localhost:8080/api/users/2",
"username": "user1"
},
"source_storage": {
"cloud_storage_id": null,
"id": 9,
"location": "local"
},
"status": "annotation",
"target_storage": {
"cloud_storage_id": null,
"id": 10,
"location": "local"
},
"task_subsets": [],
"tasks": {
"count": 0,
"url": "http://localhost:8080/api/tasks?project_id=6"
},
"updated_date": "2022-09-28T12:25:54.563000Z",
"url": "http://localhost:8080/api/projects/6"
},
{
"assignee": null,
"assignee_updated_date": null,
"bug_tracker": "",
"created_date": "2022-09-22T14:21:53.791000Z",
"dimension": "2d",
"guide_id": null,
"id": 5,
"labels": {
"url": "http://localhost:8080/api/labels?project_id=5"
},
"name": "project5",
"organization": 2,
"organization_id": 2,
"owner": {
"first_name": "Admin",
"id": 1,
"last_name": "First",
"url": "http://localhost:8080/api/users/1",
"username": "admin1"
},
"source_storage": {
"cloud_storage_id": null,
"id": 5,
"location": "local"
},
"status": "annotation",
"target_storage": {
"cloud_storage_id": null,
"id": 6,
"location": "local"
},
"task_subsets": [],
"tasks": {
"count": 1,
"url": "http://localhost:8080/api/tasks?project_id=5"
},
"updated_date": "2022-09-28T12:26:49.493000Z",
"url": "http://localhost:8080/api/projects/5"
},
{
"assignee": null,
"assignee_updated_date": null,
"bug_tracker": "",
"created_date": "2022-06-08T08:32:45.521000Z",
"dimension": "2d",
"guide_id": null,
"id": 4,
"labels": {
"url": "http://localhost:8080/api/labels?project_id=4"
},
"name": "project4",
"organization": 2,
"organization_id": 2,
"owner": {
"first_name": "Admin",
"id": 1,
"last_name": "First",
"url": "http://localhost:8080/api/users/1",
"username": "admin1"
},
"source_storage": null,
"status": "annotation",
"target_storage": null,
"task_subsets": [],
"tasks": {
"count": 1,
"url": "http://localhost:8080/api/tasks?project_id=4"
},
"updated_date": "2023-02-10T11:50:18.436000Z",
"url": "http://localhost:8080/api/projects/4"
},
{
"assignee": {
"first_name": "User",
"id": 19,
"last_name": "Fifth",
"url": "http://localhost:8080/api/users/19",
"username": "user5"
},
"assignee_updated_date": null,
"bug_tracker": "",
"created_date": "2022-03-28T13:05:24.659000Z",
"dimension": null,
"guide_id": null,
"id": 3,
"labels": {
"url": "http://localhost:8080/api/labels?project_id=3"
},
"name": "project 3",
"organization": 2,
"organization_id": 2,
"owner": {
"first_name": "User",
"id": 3,
"last_name": "Second",
"url": "http://localhost:8080/api/users/3",
"username": "user2"
},
"source_storage": null,
"status": "annotation",
"target_storage": null,
"task_subsets": [],
"tasks": {
"count": 0,
"url": "http://localhost:8080/api/tasks?project_id=3"
},
"updated_date": "2022-03-28T13:06:09.283000Z",
"url": "http://localhost:8080/api/projects/3"
},
{
"assignee": {
"first_name": "User",
"id": 3,
"last_name": "Second",
"url": "http://localhost:8080/api/users/3",
"username": "user2"
},
"assignee_updated_date": null,
"bug_tracker": "",
"created_date": "2021-12-14T19:52:37.278000Z",
"dimension": "2d",
"guide_id": null,
"id": 2,
"labels": {
"url": "http://localhost:8080/api/labels?project_id=2"
},
"name": "project2",
"organization": 2,
"organization_id": 2,
"owner": {
"first_name": "User",
"id": 10,
"last_name": "Seventh",
"url": "http://localhost:8080/api/users/10",
"username": "user7"
},
"source_storage": {
"cloud_storage_id": 3,
"id": 3,
"location": "cloud_storage"
},
"status": "annotation",
"target_storage": {
"cloud_storage_id": 3,
"id": 1,
"location": "cloud_storage"
},
"task_subsets": [
"Train"
],
"tasks": {
"count": 1,
"url": "http://localhost:8080/api/tasks?project_id=2"
},
"updated_date": "2022-06-30T08:56:45.601000Z",
"url": "http://localhost:8080/api/projects/2"
},
{
"assignee": {
"first_name": "User",
"id": 20,
"last_name": "Sixth",
"url": "http://localhost:8080/api/users/20",
"username": "user6"
},
"assignee_updated_date": null,
"bug_tracker": "",
"created_date": "2021-12-14T19:46:37.969000Z",
"dimension": "2d",
"guide_id": null,
"id": 1,
"labels": {
"url": "http://localhost:8080/api/labels?project_id=1"
},
"name": "project1",
"organization": null,
"organization_id": null,
"owner": {
"first_name": "User",
"id": 10,
"last_name": "Seventh",
"url": "http://localhost:8080/api/users/10",
"username": "user7"
},
"source_storage": null,
"status": "annotation",
"target_storage": null,
"task_subsets": [],
"tasks": {
"count": 1,
"url": "http://localhost:8080/api/tasks?project_id=1"
},
"updated_date": "2022-11-03T13:57:25.895000Z",
"url": "http://localhost:8080/api/projects/1"
}
]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,679 @@
{
"count": 18,
"next": null,
"previous": null,
"results": [
{
"assignee": null,
"created_date": "2025-05-06T16:27:52.046000Z",
"gt_last_updated": null,
"id": 18,
"job_id": null,
"parent_id": null,
"project_id": 17,
"summary": {
"accuracy": 0.2727272727272727,
"conflict_count": 10,
"conflicts_by_type": {
"extra_annotation": 1,
"low_overlap": 2,
"missing_annotation": 7
},
"ds_count": 8,
"error_count": 8,
"frame_count": 6,
"frame_share": 0.5,
"gt_count": 20,
"jobs": {
"excluded": 0,
"included": 3,
"not_checkable": 0,
"total": 3
},
"precision": 0.75,
"recall": 0.3,
"tasks": {
"custom": 0,
"excluded": 0,
"included": 2,
"not_configured": 0,
"total": 2
},
"total_count": 22,
"total_frames": 12,
"valid_count": 6,
"validation_frame_share": 0.5,
"validation_frames": 6,
"warning_count": 2
},
"target": "project",
"target_last_updated": "2025-05-05T15:35:00.542000Z",
"task_id": null
},
{
"assignee": null,
"created_date": "2025-05-06T16:27:52.036000Z",
"gt_last_updated": "2025-05-05T15:38:37.188000Z",
"id": 17,
"job_id": 57,
"parent_id": 15,
"project_id": 17,
"summary": {
"accuracy": 0.25,
"conflict_count": 3,
"conflicts_by_type": {
"extra_annotation": 1,
"missing_annotation": 2
},
"ds_count": 2,
"error_count": 3,
"frame_count": 2,
"frame_share": 0.6666666666666666,
"gt_count": 3,
"precision": 0.5,
"recall": 0.3333333333333333,
"total_count": 4,
"total_frames": 3,
"valid_count": 1,
"validation_frame_share": 0.6666666666666666,
"validation_frames": 2,
"warning_count": 0
},
"target": "job",
"target_last_updated": "2025-05-05T15:35:00.515000Z",
"task_id": 33
},
{
"assignee": null,
"created_date": "2025-05-06T16:27:52.036000Z",
"gt_last_updated": "2025-05-05T15:38:37.188000Z",
"id": 16,
"job_id": 56,
"parent_id": 15,
"project_id": 17,
"summary": {
"accuracy": 0.3333333333333333,
"conflict_count": 3,
"conflicts_by_type": {
"low_overlap": 1,
"missing_annotation": 2
},
"ds_count": 1,
"error_count": 2,
"frame_count": 2,
"frame_share": 0.4,
"gt_count": 3,
"precision": 1.0,
"recall": 0.3333333333333333,
"total_count": 3,
"total_frames": 5,
"valid_count": 1,
"validation_frame_share": 0.4,
"validation_frames": 2,
"warning_count": 1
},
"target": "job",
"target_last_updated": "2025-05-05T15:35:00.493000Z",
"task_id": 33
},
{
"assignee": null,
"created_date": "2025-05-06T16:27:52.034000Z",
"gt_last_updated": "2025-05-05T15:38:37.188000Z",
"id": 15,
"job_id": null,
"parent_id": 18,
"project_id": 17,
"summary": {
"accuracy": 0.2857142857142857,
"conflict_count": 6,
"conflicts_by_type": {
"extra_annotation": 1,
"low_overlap": 1,
"missing_annotation": 4
},
"ds_count": 3,
"error_count": 5,
"frame_count": 4,
"frame_share": 0.5,
"gt_count": 6,
"jobs": {
"excluded": 0,
"included": 2,
"not_checkable": 0,
"total": 2
},
"precision": 0.6666666666666666,
"recall": 0.3333333333333333,
"total_count": 7,
"total_frames": 8,
"valid_count": 2,
"validation_frame_share": 0.5,
"validation_frames": 4,
"warning_count": 1
},
"target": "task",
"target_last_updated": "2025-05-05T15:36:34.341000Z",
"task_id": 33
},
{
"assignee": null,
"created_date": "2025-05-06T16:27:51.967000Z",
"gt_last_updated": "2025-05-05T15:38:29.597000Z",
"id": 14,
"job_id": 54,
"parent_id": 13,
"project_id": 17,
"summary": {
"accuracy": 0.25,
"conflict_count": 4,
"conflicts_by_type": {
"low_overlap": 1,
"missing_annotation": 3
},
"ds_count": 1,
"error_count": 3,
"frame_count": 2,
"frame_share": 0.5,
"gt_count": 4,
"precision": 1.0,
"recall": 0.25,
"total_count": 4,
"total_frames": 4,
"valid_count": 1,
"validation_frame_share": 0.5,
"validation_frames": 2,
"warning_count": 1
},
"target": "job",
"target_last_updated": "2025-05-05T15:35:00.294000Z",
"task_id": 32
},
{
"assignee": null,
"created_date": "2025-05-06T16:27:51.965000Z",
"gt_last_updated": "2025-05-05T15:38:29.597000Z",
"id": 13,
"job_id": null,
"parent_id": 18,
"project_id": 17,
"summary": {
"accuracy": 0.25,
"conflict_count": 4,
"conflicts_by_type": {
"low_overlap": 1,
"missing_annotation": 3
},
"ds_count": 1,
"error_count": 3,
"frame_count": 2,
"frame_share": 0.5,
"gt_count": 4,
"jobs": {
"excluded": 0,
"included": 1,
"not_checkable": 0,
"total": 1
},
"precision": 1.0,
"recall": 0.25,
"total_count": 4,
"total_frames": 4,
"valid_count": 1,
"validation_frame_share": 0.5,
"validation_frames": 2,
"warning_count": 1
},
"target": "task",
"target_last_updated": "2025-05-05T15:38:46.662000Z",
"task_id": 32
},
{
"assignee": null,
"created_date": "2025-03-31T13:38:00.464000Z",
"gt_last_updated": "2023-11-24T15:18:55.216000Z",
"id": 12,
"job_id": 27,
"parent_id": 11,
"project_id": null,
"summary": {
"accuracy": 0.4583333333333333,
"conflict_count": 42,
"conflicts_by_type": {
"covered_annotation": 1,
"extra_annotation": 11,
"low_overlap": 7,
"mismatching_attributes": 1,
"mismatching_direction": 1,
"mismatching_groups": 6,
"mismatching_label": 3,
"missing_annotation": 12
},
"ds_count": 36,
"error_count": 26,
"frame_count": 3,
"frame_share": 0.2727272727272727,
"gt_count": 37,
"precision": 0.6111111111111112,
"recall": 0.5945945945945946,
"total_count": 48,
"total_frames": 11,
"valid_count": 22,
"validation_frame_share": 0.2727272727272727,
"validation_frames": 3,
"warning_count": 16
},
"target": "job",
"target_last_updated": "2023-11-24T15:23:30.269000Z",
"task_id": 22
},
{
"assignee": null,
"created_date": "2025-03-31T13:38:00.460000Z",
"gt_last_updated": "2023-11-24T15:18:55.216000Z",
"id": 11,
"job_id": null,
"parent_id": null,
"project_id": null,
"summary": {
"accuracy": 0.4583333333333333,
"conflict_count": 42,
"conflicts_by_type": {
"covered_annotation": 1,
"extra_annotation": 11,
"low_overlap": 7,
"mismatching_attributes": 1,
"mismatching_direction": 1,
"mismatching_groups": 6,
"mismatching_label": 3,
"missing_annotation": 12
},
"ds_count": 36,
"error_count": 26,
"frame_count": 3,
"frame_share": 0.2727272727272727,
"gt_count": 37,
"jobs": {
"excluded": 0,
"included": 1,
"not_checkable": 0,
"total": 1
},
"precision": 0.6111111111111112,
"recall": 0.5945945945945946,
"total_count": 48,
"total_frames": 11,
"valid_count": 22,
"validation_frame_share": 0.2727272727272727,
"validation_frames": 3,
"warning_count": 16
},
"target": "task",
"target_last_updated": "2023-11-24T15:23:30.045000Z",
"task_id": 22
},
{
"assignee": null,
"created_date": "2024-03-21T20:52:03.552000Z",
"gt_last_updated": "2024-03-21T20:50:20.020000Z",
"id": 10,
"job_id": 29,
"parent_id": 7,
"project_id": null,
"summary": {
"accuracy": 0.0,
"conflict_count": 2,
"conflicts_by_type": {
"extra_annotation": 1,
"missing_annotation": 1
},
"ds_count": 1,
"error_count": 2,
"frame_count": 1,
"frame_share": 0.2,
"gt_count": 1,
"precision": 0.0,
"recall": 0.0,
"total_count": 2,
"total_frames": 5,
"valid_count": 0,
"validation_frame_share": 0.2,
"validation_frames": 1,
"warning_count": 0
},
"target": "job",
"target_last_updated": "2024-03-21T20:50:39.585000Z",
"task_id": 23
},
{
"assignee": null,
"created_date": "2024-03-21T20:52:03.552000Z",
"gt_last_updated": "2024-03-21T20:50:20.020000Z",
"id": 9,
"job_id": 30,
"parent_id": 7,
"project_id": null,
"summary": {
"accuracy": 1.0,
"conflict_count": 1,
"conflicts_by_type": {
"low_overlap": 1
},
"ds_count": 2,
"error_count": 0,
"frame_count": 2,
"frame_share": 0.4,
"gt_count": 2,
"precision": 1.0,
"recall": 1.0,
"total_count": 2,
"total_frames": 5,
"valid_count": 2,
"validation_frame_share": 0.4,
"validation_frames": 2,
"warning_count": 1
},
"target": "job",
"target_last_updated": "2024-03-21T20:50:33.610000Z",
"task_id": 23
},
{
"assignee": null,
"created_date": "2024-03-21T20:52:03.552000Z",
"gt_last_updated": "2024-03-21T20:50:20.020000Z",
"id": 8,
"job_id": 31,
"parent_id": 7,
"project_id": null,
"summary": {
"accuracy": 0.0,
"conflict_count": 0,
"conflicts_by_type": {},
"ds_count": 0,
"error_count": 0,
"frame_count": 0,
"frame_share": 0.0,
"gt_count": 0,
"precision": 0.0,
"recall": 0.0,
"total_count": 0,
"total_frames": 0,
"valid_count": 0,
"validation_frame_share": 0.0,
"validation_frames": 0,
"warning_count": 0
},
"target": "job",
"target_last_updated": "2024-03-21T20:50:27.594000Z",
"task_id": 23
},
{
"assignee": null,
"created_date": "2024-03-21T20:52:03.542000Z",
"gt_last_updated": "2024-03-21T20:50:20.020000Z",
"id": 7,
"job_id": null,
"parent_id": null,
"project_id": null,
"summary": {
"accuracy": 0.5,
"conflict_count": 3,
"conflicts_by_type": {
"extra_annotation": 1,
"low_overlap": 1,
"missing_annotation": 1
},
"ds_count": 3,
"error_count": 2,
"frame_count": 3,
"frame_share": 0.2727272727272727,
"gt_count": 3,
"precision": 0.6666666666666666,
"recall": 0.6666666666666666,
"total_count": 4,
"total_frames": 11,
"valid_count": 2,
"validation_frame_share": 0.2727272727272727,
"validation_frames": 3,
"warning_count": 1
},
"target": "task",
"target_last_updated": "2024-03-21T20:50:05.947000Z",
"task_id": 23
},
{
"assignee": null,
"created_date": "2024-03-21T11:16:21.847000Z",
"gt_last_updated": "2023-11-24T15:18:55.216000Z",
"id": 6,
"job_id": 27,
"parent_id": 5,
"project_id": null,
"summary": {
"accuracy": 0.4782608695652174,
"conflict_count": 40,
"conflicts_by_type": {
"covered_annotation": 1,
"extra_annotation": 11,
"low_overlap": 7,
"mismatching_attributes": 1,
"mismatching_direction": 1,
"mismatching_groups": 6,
"mismatching_label": 3,
"missing_annotation": 10
},
"ds_count": 36,
"error_count": 24,
"frame_count": 3,
"frame_share": 0.2727272727272727,
"gt_count": 35,
"precision": 0.6111111111111112,
"recall": 0.6285714285714286,
"total_count": 46,
"total_frames": 11,
"valid_count": 22,
"validation_frame_share": 0.2727272727272727,
"validation_frames": 3,
"warning_count": 16
},
"target": "job",
"target_last_updated": "2023-11-24T15:23:30.269000Z",
"task_id": 22
},
{
"assignee": null,
"created_date": "2024-03-21T11:16:21.845000Z",
"gt_last_updated": "2023-11-24T15:18:55.216000Z",
"id": 5,
"job_id": null,
"parent_id": null,
"project_id": null,
"summary": {
"accuracy": 0.4782608695652174,
"conflict_count": 40,
"conflicts_by_type": {
"covered_annotation": 1,
"extra_annotation": 11,
"low_overlap": 7,
"mismatching_attributes": 1,
"mismatching_direction": 1,
"mismatching_groups": 6,
"mismatching_label": 3,
"missing_annotation": 10
},
"ds_count": 36,
"error_count": 24,
"frame_count": 3,
"frame_share": 0.2727272727272727,
"gt_count": 35,
"precision": 0.6111111111111112,
"recall": 0.6285714285714286,
"total_count": 46,
"total_frames": 11,
"valid_count": 22,
"validation_frame_share": 0.2727272727272727,
"validation_frames": 3,
"warning_count": 16
},
"target": "task",
"target_last_updated": "2023-11-24T15:23:30.045000Z",
"task_id": 22
},
{
"assignee": null,
"created_date": "2023-11-24T15:24:25.357000Z",
"gt_last_updated": "2023-11-24T15:18:55.216000Z",
"id": 4,
"job_id": 27,
"parent_id": 3,
"project_id": null,
"summary": {
"accuracy": 0.4782608695652174,
"conflict_count": 40,
"conflicts_by_type": {
"covered_annotation": 1,
"extra_annotation": 11,
"low_overlap": 7,
"mismatching_attributes": 1,
"mismatching_direction": 1,
"mismatching_groups": 6,
"mismatching_label": 3,
"missing_annotation": 10
},
"ds_count": 36,
"error_count": 24,
"frame_count": 3,
"frame_share": 0.2727272727272727,
"gt_count": 35,
"precision": 0.6111111111111112,
"recall": 0.6285714285714286,
"total_count": 46,
"total_frames": 11,
"valid_count": 22,
"validation_frame_share": 0.2727272727272727,
"validation_frames": 3,
"warning_count": 16
},
"target": "job",
"target_last_updated": "2023-11-24T15:23:30.269000Z",
"task_id": 22
},
{
"assignee": null,
"created_date": "2023-11-24T15:24:25.355000Z",
"gt_last_updated": "2023-11-24T15:18:55.216000Z",
"id": 3,
"job_id": null,
"parent_id": null,
"project_id": null,
"summary": {
"accuracy": 0.4782608695652174,
"conflict_count": 40,
"conflicts_by_type": {
"covered_annotation": 1,
"extra_annotation": 11,
"low_overlap": 7,
"mismatching_attributes": 1,
"mismatching_direction": 1,
"mismatching_groups": 6,
"mismatching_label": 3,
"missing_annotation": 10
},
"ds_count": 36,
"error_count": 24,
"frame_count": 3,
"frame_share": 0.2727272727272727,
"gt_count": 35,
"precision": 0.6111111111111112,
"recall": 0.6285714285714286,
"total_count": 46,
"total_frames": 11,
"valid_count": 22,
"validation_frame_share": 0.2727272727272727,
"validation_frames": 3,
"warning_count": 16
},
"target": "task",
"target_last_updated": "2023-11-24T15:23:30.045000Z",
"task_id": 22
},
{
"assignee": null,
"created_date": "2023-05-26T16:25:36.616000Z",
"gt_last_updated": "2023-05-26T16:16:50.630000Z",
"id": 2,
"job_id": 27,
"parent_id": 1,
"project_id": null,
"summary": {
"accuracy": 0.4883720930232558,
"conflict_count": 37,
"conflicts_by_type": {
"covered_annotation": 1,
"extra_annotation": 10,
"low_overlap": 6,
"mismatching_attributes": 1,
"mismatching_direction": 1,
"mismatching_groups": 6,
"mismatching_label": 3,
"missing_annotation": 9
},
"ds_count": 34,
"error_count": 22,
"frame_count": 3,
"frame_share": 0.2727272727272727,
"gt_count": 33,
"precision": 0.6176470588235294,
"recall": 0.6363636363636364,
"total_count": 43,
"total_frames": 11,
"valid_count": 21,
"validation_frame_share": 0.2727272727272727,
"validation_frames": 3,
"warning_count": 15
},
"target": "job",
"target_last_updated": "2023-05-26T16:11:24.294000Z",
"task_id": 22
},
{
"assignee": null,
"created_date": "2023-05-26T16:25:36.613000Z",
"gt_last_updated": "2023-05-26T16:16:50.630000Z",
"id": 1,
"job_id": null,
"parent_id": null,
"project_id": null,
"summary": {
"accuracy": 0.4883720930232558,
"conflict_count": 37,
"conflicts_by_type": {
"covered_annotation": 1,
"extra_annotation": 10,
"low_overlap": 6,
"mismatching_attributes": 1,
"mismatching_direction": 1,
"mismatching_groups": 6,
"mismatching_label": 3,
"missing_annotation": 9
},
"ds_count": 34,
"error_count": 22,
"frame_count": 3,
"frame_share": 0.2727272727272727,
"gt_count": 33,
"precision": 0.6176470588235294,
"recall": 0.6363636363636364,
"total_count": 43,
"total_frames": 11,
"valid_count": 21,
"validation_frame_share": 0.2727272727272727,
"validation_frames": 3,
"warning_count": 15
},
"target": "task",
"target_last_updated": "2023-05-26T16:17:02.635000Z",
"task_id": 22
}
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+356
View File
@@ -0,0 +1,356 @@
{
"count": 21,
"next": null,
"previous": null,
"results": [
{
"date_joined": "2023-03-30T09:37:31.615000Z",
"email": "lonely_user@cvat.org",
"first_name": "Lonely",
"groups": [
"user"
],
"has_analytics_access": false,
"id": 21,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": "2023-03-30T09:37:31.708000Z",
"last_name": "User",
"url": "http://localhost:8080/api/users/21",
"username": "lonely_user"
},
{
"date_joined": "2022-02-24T20:45:19Z",
"email": "user6@cvat.org",
"first_name": "User",
"groups": [
"user"
],
"has_analytics_access": false,
"id": 20,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": null,
"last_name": "Sixth",
"url": "http://localhost:8080/api/users/20",
"username": "user6"
},
{
"date_joined": "2022-02-24T20:45:07Z",
"email": "user5@cvat.org",
"first_name": "User",
"groups": [
"user"
],
"has_analytics_access": false,
"id": 19,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": null,
"last_name": "Fifth",
"url": "http://localhost:8080/api/users/19",
"username": "user5"
},
{
"date_joined": "2021-12-14T18:38:46Z",
"email": "admin2@cvat.org",
"first_name": "Admin",
"groups": [
"admin"
],
"has_analytics_access": true,
"id": 18,
"is_active": true,
"is_staff": true,
"is_superuser": true,
"last_login": "2022-03-05T09:37:01.636000Z",
"last_name": "Second",
"url": "http://localhost:8080/api/users/18",
"username": "admin2"
},
{
"date_joined": "2021-12-14T18:37:41Z",
"email": "dummy4@cvat.org",
"first_name": "Dummy",
"groups": [],
"has_analytics_access": false,
"id": 17,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": null,
"last_name": "Fourth",
"url": "http://localhost:8080/api/users/17",
"username": "dummy4"
},
{
"date_joined": "2021-12-14T18:37:09Z",
"email": "dummy3@cvat.org",
"first_name": "Dummy",
"groups": [],
"has_analytics_access": false,
"id": 16,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": null,
"last_name": "Third",
"url": "http://localhost:8080/api/users/16",
"username": "dummy3"
},
{
"date_joined": "2021-12-14T18:36:31Z",
"email": "dummy2@cvat.org",
"first_name": "Dummy",
"groups": [],
"has_analytics_access": false,
"id": 15,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": null,
"last_name": "Second",
"url": "http://localhost:8080/api/users/15",
"username": "dummy2"
},
{
"date_joined": "2021-12-14T18:36:00Z",
"email": "dummy1@cvat.org",
"first_name": "Dummy",
"groups": [],
"has_analytics_access": false,
"id": 14,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": null,
"last_name": "First",
"url": "http://localhost:8080/api/users/14",
"username": "dummy1"
},
{
"date_joined": "2021-12-14T18:35:15Z",
"email": "user10@cvat.org",
"first_name": "User",
"groups": [
"user"
],
"has_analytics_access": false,
"id": 13,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": null,
"last_name": "Tenth",
"url": "http://localhost:8080/api/users/13",
"username": "user10"
},
{
"date_joined": "2021-12-14T18:34:34Z",
"email": "user9@cvat.org",
"first_name": "User",
"groups": [
"user"
],
"has_analytics_access": false,
"id": 12,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": null,
"last_name": "Ninth",
"url": "http://localhost:8080/api/users/12",
"username": "user9"
},
{
"date_joined": "2021-12-14T18:34:01Z",
"email": "user8@cvat.org",
"first_name": "User",
"groups": [
"user"
],
"has_analytics_access": false,
"id": 11,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": "2022-03-17T07:22:55.930000Z",
"last_name": "Eighth",
"url": "http://localhost:8080/api/users/11",
"username": "user8"
},
{
"date_joined": "2021-12-14T18:33:06Z",
"email": "user7@cvat.org",
"first_name": "User",
"groups": [
"user"
],
"has_analytics_access": false,
"id": 10,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": "2022-09-28T12:17:51.373000Z",
"last_name": "Seventh",
"url": "http://localhost:8080/api/users/10",
"username": "user7"
},
{
"date_joined": "2021-12-14T18:32:01Z",
"email": "worker4@cvat.org",
"first_name": "Worker",
"groups": [
"worker"
],
"has_analytics_access": true,
"id": 9,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": null,
"last_name": "Fourth",
"url": "http://localhost:8080/api/users/9",
"username": "worker4"
},
{
"date_joined": "2021-12-14T18:31:25Z",
"email": "worker3@cvat.org",
"first_name": "Worker",
"groups": [
"worker"
],
"has_analytics_access": false,
"id": 8,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": null,
"last_name": "Third",
"url": "http://localhost:8080/api/users/8",
"username": "worker3"
},
{
"date_joined": "2021-12-14T18:30:43Z",
"email": "worker2@cvat.org",
"first_name": "Worker",
"groups": [
"worker"
],
"has_analytics_access": false,
"id": 7,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": null,
"last_name": "Second",
"url": "http://localhost:8080/api/users/7",
"username": "worker2"
},
{
"date_joined": "2021-12-14T18:30:00Z",
"email": "worker1@cvat.org",
"first_name": "Worker",
"groups": [
"worker"
],
"has_analytics_access": false,
"id": 6,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": "2022-09-06T07:57:19.879000Z",
"last_name": "First",
"url": "http://localhost:8080/api/users/6",
"username": "worker1"
},
{
"date_joined": "2021-12-14T18:25:10Z",
"email": "user4@cvat.org",
"first_name": "User",
"groups": [
"user"
],
"has_analytics_access": true,
"id": 5,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": null,
"last_name": "Fourth",
"url": "http://localhost:8080/api/users/5",
"username": "user4"
},
{
"date_joined": "2021-12-14T18:24:39Z",
"email": "user3@cvat.org",
"first_name": "User",
"groups": [
"user"
],
"has_analytics_access": false,
"id": 4,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": null,
"last_name": "Third",
"url": "http://localhost:8080/api/users/4",
"username": "user3"
},
{
"date_joined": "2021-12-14T18:24:12Z",
"email": "user2@cvat.org",
"first_name": "User",
"groups": [
"user"
],
"has_analytics_access": false,
"id": 3,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": "2022-09-28T12:19:33.698000Z",
"last_name": "Second",
"url": "http://localhost:8080/api/users/3",
"username": "user2"
},
{
"date_joined": "2021-12-14T18:21:09Z",
"email": "user1@cvat.org",
"first_name": "User",
"groups": [
"user"
],
"has_analytics_access": false,
"id": 2,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": "2023-09-15T07:52:46.964000Z",
"last_name": "First",
"url": "http://localhost:8080/api/users/2",
"username": "user1"
},
{
"date_joined": "2021-12-14T18:04:57Z",
"email": "admin1@cvat.org",
"first_name": "Admin",
"groups": [
"admin"
],
"has_analytics_access": true,
"id": 1,
"is_active": true,
"is_staff": true,
"is_superuser": true,
"last_login": "2026-01-15T08:22:10.292000Z",
"last_name": "First",
"url": "http://localhost:8080/api/users/1",
"username": "admin1"
}
]
}
+138
View File
@@ -0,0 +1,138 @@
{
"count": 4,
"next": null,
"previous": null,
"results": [
{
"content_type": "application/json",
"created_date": "2022-09-29T08:00:48.440000Z",
"description": "",
"enable_ssl": true,
"events": [
"create:comment",
"create:invitation",
"create:issue",
"create:project",
"create:task",
"delete:comment",
"delete:invitation",
"delete:issue",
"delete:membership",
"delete:project",
"delete:task",
"update:comment",
"update:issue",
"update:job",
"update:membership",
"update:organization",
"update:project",
"update:task"
],
"id": 6,
"is_active": true,
"last_delivery_date": "2023-09-15T07:53:53.135000Z",
"last_status": 200,
"organization": 1,
"owner": {
"first_name": "Admin",
"id": 1,
"last_name": "First",
"url": "http://localhost:8080/api/users/1",
"username": "admin1"
},
"project_id": null,
"target_url": "http://webhooks.internal/",
"type": "organization",
"updated_date": "2022-09-29T08:00:48.441000Z",
"url": "http://localhost:8080/api/webhooks/6"
},
{
"content_type": "application/json",
"created_date": "2022-09-28T12:19:49.744000Z",
"description": "",
"enable_ssl": true,
"events": [
"create:issue",
"delete:issue",
"update:issue",
"update:project"
],
"id": 3,
"is_active": true,
"organization": 2,
"owner": {
"first_name": "User",
"id": 3,
"last_name": "Second",
"url": "http://localhost:8080/api/users/3",
"username": "user2"
},
"project_id": 3,
"target_url": "http://webhooks.internal",
"type": "project",
"updated_date": "2022-09-28T12:19:49.744000Z",
"url": "http://localhost:8080/api/webhooks/3"
},
{
"content_type": "application/json",
"created_date": "2022-09-28T12:18:12.412000Z",
"description": "",
"enable_ssl": true,
"events": [
"create:comment",
"create:issue",
"create:task",
"delete:comment",
"delete:issue",
"delete:task",
"update:comment",
"update:issue",
"update:job",
"update:project",
"update:task"
],
"id": 2,
"is_active": true,
"organization": null,
"owner": {
"first_name": "User",
"id": 10,
"last_name": "Seventh",
"url": "http://localhost:8080/api/users/10",
"username": "user7"
},
"project_id": 1,
"target_url": "http://webhooks.internal/",
"type": "project",
"updated_date": "2022-09-28T12:18:12.412000Z",
"url": "http://localhost:8080/api/webhooks/2"
},
{
"content_type": "application/json",
"created_date": "2022-09-28T12:16:28.311000Z",
"description": "",
"enable_ssl": true,
"events": [
"create:task",
"delete:task",
"update:job",
"update:task"
],
"id": 1,
"is_active": true,
"organization": null,
"owner": {
"first_name": "User",
"id": 2,
"last_name": "First",
"url": "http://localhost:8080/api/users/2",
"username": "user1"
},
"project_id": 6,
"target_url": "http://webhooks.internal/",
"type": "project",
"updated_date": "2022-09-28T12:16:28.311000Z",
"url": "http://localhost:8080/api/webhooks/1"
}
]
}
+628
View File
@@ -0,0 +1,628 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import json
import operator
from collections import defaultdict
from collections.abc import Generator, Iterable
from copy import deepcopy
from pathlib import Path
import pytest
from shared.utils.config import ASSETS_DIR, SHARE_DIR
class Container:
def __init__(self, data, key="id"):
self.raw_data = data
self.map_data = {obj[key]: obj for obj in data}
@property
def raw(self):
return self.raw_data
@property
def map(self):
return self.map_data
def __iter__(self):
return iter(self.raw_data)
def __len__(self):
return len(self.raw_data)
def __getitem__(self, key):
if isinstance(key, slice):
return self.raw_data[key]
return self.map_data[key]
@pytest.fixture(scope="session")
def users():
with open(ASSETS_DIR / "users.json") as f:
return Container(json.load(f)["results"])
@pytest.fixture(scope="session")
def organizations():
with open(ASSETS_DIR / "organizations.json") as f:
return Container(json.load(f)["results"])
@pytest.fixture(scope="session")
def memberships():
with open(ASSETS_DIR / "memberships.json") as f:
return Container(json.load(f)["results"])
@pytest.fixture(scope="session")
def tasks():
with open(ASSETS_DIR / "tasks.json") as f:
return Container(json.load(f)["results"])
def filter_assets(resources: Iterable, **kwargs):
filtered_resources = []
exclude_prefix = "exclude_"
filter_operator = lambda arg, func: bool(func(arg))
for resource in resources:
is_matched = True
for key, value in kwargs.items():
if not is_matched:
break
op = operator.eq
if key.startswith(exclude_prefix):
key = key[len(exclude_prefix) :]
op = operator.ne
elif callable(value):
op = filter_operator
cur_value, rest = resource, key
while rest:
field_and_rest = rest.split("__", maxsplit=1)
if 2 == len(field_and_rest):
field, rest = field_and_rest
else:
field, rest = field_and_rest[0], None
cur_value = cur_value.get(field, None)
# e.g. task has null target_storage
# or there are mutexed project_id, task_id
if not cur_value:
break
if not (not rest and op(cur_value, value) or rest and op == operator.ne):
is_matched = False
if is_matched:
filtered_resources.append(resource)
return filtered_resources
@pytest.fixture(scope="session")
def filter_projects(projects):
def filter_(**kwargs):
return filter_assets(projects, **kwargs)
return filter_
@pytest.fixture(scope="session")
def filter_tasks(tasks):
def filter_(**kwargs):
return filter_assets(tasks, **kwargs)
return filter_
@pytest.fixture(scope="session")
def tasks_wlc(labels, tasks): # tasks with labels count
tasks = deepcopy(tasks)
tasks_by_project = defaultdict(list)
for task in tasks:
tasks_by_project[task["project_id"]].append(task)
task["labels"]["count"] = 0
for label in labels:
task_id = label.get("task_id")
project_id = label.get("project_id")
if not label["parent_id"]:
if task_id:
tasks[task_id]["labels"]["count"] += 1
elif project_id:
for task in tasks_by_project[project_id]:
task["labels"]["count"] += 1
return tasks
@pytest.fixture(scope="session")
def projects():
with open(ASSETS_DIR / "projects.json") as f:
return Container(json.load(f)["results"])
@pytest.fixture(scope="session")
def projects_wlc(projects, labels): # projects with labels count
projects = deepcopy(projects)
for project in projects:
project["labels"]["count"] = 0
for label in labels:
project_id = label.get("project_id")
if not label["parent_id"] and project_id:
projects[project_id]["labels"]["count"] += 1
return projects
@pytest.fixture(scope="session")
def jobs():
with open(ASSETS_DIR / "jobs.json") as f:
return Container(json.load(f)["results"])
@pytest.fixture(scope="session")
def jobs_wlc(jobs, tasks_wlc): # jobs with labels count
jobs = deepcopy(jobs)
for job in jobs:
tid = job["task_id"]
job["labels"]["count"] = tasks_wlc[tid]["labels"]["count"]
return jobs
@pytest.fixture(scope="session")
def invitations():
with open(ASSETS_DIR / "invitations.json") as f:
return Container(json.load(f)["results"], key="key")
@pytest.fixture(scope="session")
def annotations():
with open(ASSETS_DIR / "annotations.json") as f:
return json.load(f)
CloudStorageAssets = Container
@pytest.fixture(scope="session")
def cloud_storages() -> CloudStorageAssets:
with open(ASSETS_DIR / "cloudstorages.json") as f:
return Container(json.load(f)["results"])
@pytest.fixture(scope="session")
def issues():
with open(ASSETS_DIR / "issues.json") as f:
return Container(json.load(f)["results"])
@pytest.fixture(scope="session")
def comments():
with open(ASSETS_DIR / "comments.json") as f:
return Container(json.load(f)["results"])
@pytest.fixture(scope="session")
def webhooks():
with open(ASSETS_DIR / "webhooks.json") as f:
return Container(json.load(f)["results"])
@pytest.fixture(scope="session")
def labels():
with open(ASSETS_DIR / "labels.json") as f:
return Container(json.load(f)["results"])
@pytest.fixture(scope="session")
def quality_reports():
with open(ASSETS_DIR / "quality_reports.json") as f:
return Container(json.load(f)["results"])
@pytest.fixture(scope="session")
def quality_conflicts():
with open(ASSETS_DIR / "quality_conflicts.json") as f:
return Container(json.load(f)["results"])
@pytest.fixture(scope="session")
def quality_settings():
with open(ASSETS_DIR / "quality_settings.json") as f:
return Container(json.load(f)["results"])
@pytest.fixture(scope="session")
def consensus_settings():
with open(ASSETS_DIR / "consensus_settings.json") as f:
return Container(json.load(f)["results"])
@pytest.fixture(scope="session")
def users_by_name(users):
return {user["username"]: user for user in users}
@pytest.fixture(scope="session")
def jobs_by_org(tasks, jobs):
# FUTURE-FIXME: should be based on organizations to include orgs without jobs too
data = {}
for job in jobs:
data.setdefault(tasks[job["task_id"]]["organization"], []).append(job)
data[""] = data.pop(None, [])
return data
@pytest.fixture(scope="session")
def projects_by_org(projects):
data = {}
for project in projects:
data.setdefault(project["organization"], []).append(project)
data[""] = data.pop(None, [])
return data
@pytest.fixture(scope="session")
def tasks_by_org(tasks):
data = {}
for task in tasks:
data.setdefault(task["organization"], []).append(task)
data[""] = data.pop(None, [])
return data
@pytest.fixture(scope="session")
def issues_by_org(tasks, jobs, issues):
data = {}
for issue in issues:
data.setdefault(tasks[jobs[issue["job"]]["task_id"]]["organization"], []).append(issue)
data[""] = data.pop(None, [])
return data
@pytest.fixture(scope="session")
def assignee_id():
def get_id(data):
if data.get("assignee") is not None:
return data["assignee"]["id"]
return get_id
def ownership(func):
def wrap(user_id, resource_id):
if resource_id is None:
return False
return func(user_id, resource_id)
return wrap
@pytest.fixture(scope="session")
def is_project_staff(projects, assignee_id):
@ownership
def check(user_id, pid):
return user_id == projects[pid]["owner"]["id"] or user_id == assignee_id(projects[pid])
return check
@pytest.fixture(scope="session")
def is_task_staff(tasks, is_project_staff, assignee_id):
@ownership
def check(user_id, tid):
return (
user_id == tasks[tid]["owner"]["id"]
or user_id == assignee_id(tasks[tid])
or is_project_staff(user_id, tasks[tid]["project_id"])
)
return check
@pytest.fixture(scope="session")
def is_job_staff(jobs, is_task_staff, assignee_id):
@ownership
def check(user_id, jid):
return user_id == assignee_id(jobs[jid]) or is_task_staff(user_id, jobs[jid]["task_id"])
return check
@pytest.fixture(scope="session")
def is_issue_staff(issues, jobs, assignee_id):
@ownership
def check(user_id, issue_id):
return (
user_id == issues[issue_id]["owner"]["id"]
or user_id == assignee_id(issues[issue_id])
or user_id == assignee_id(jobs[issues[issue_id]["job"]])
)
return check
@pytest.fixture(scope="session")
def is_issue_admin(issues, jobs, is_task_staff):
@ownership
def check(user_id, issue_id):
return is_task_staff(user_id, jobs[issues[issue_id]["job"]]["task_id"])
return check
@pytest.fixture(scope="session")
def find_users(test_db):
def find(**kwargs):
assert len(kwargs) > 0
data = test_db
for field, value in kwargs.items():
if field.startswith("exclude_"):
field = field.split("_", maxsplit=1)[1]
exclude_rows = set(v["id"] for v in filter(lambda a: a[field] == value, test_db))
data = list(filter(lambda a: a["id"] not in exclude_rows, data))
else:
data = list(filter(lambda a: a[field] == value, data))
return data
return find
@pytest.fixture(scope="session")
def test_db(users, users_by_name, memberships):
data = []
fields = [
"username",
"id",
"privilege",
"role",
"org",
"membership_id",
"is_superuser",
"has_analytics_access",
]
def add_row(**kwargs):
data.append({field: kwargs.get(field) for field in fields})
for user in users:
for group in user["groups"]:
add_row(
username=user["username"],
id=user["id"],
privilege=group,
has_analytics_access=user["has_analytics_access"],
is_superuser=user["is_superuser"],
)
for membership in memberships:
username = membership["user"]["username"]
for group in users_by_name[username]["groups"]:
add_row(
username=username,
role=membership["role"],
privilege=group,
id=membership["user"]["id"],
org=membership["organization"],
membership_id=membership["id"],
has_analytics_access=users_by_name[username]["has_analytics_access"],
is_superuser=users_by_name[username]["is_superuser"],
)
return data
@pytest.fixture(scope="session")
def org_staff(memberships):
def find(org_id):
if org_id in ["", None]:
return set()
else:
return set(
m["user"]["id"]
for m in memberships
if m["role"] in ["maintainer", "owner"]
and m["user"] is not None
and m["organization"] == org_id
)
return find
@pytest.fixture(scope="session")
def is_org_member(memberships):
def check(user_id, org_id, *, role=None):
if org_id in ["", None]:
return True
else:
return user_id in set(
m["user"]["id"]
for m in memberships
if m["user"] is not None
if m["organization"] == org_id
if not role or m["role"] == role
)
return check
@pytest.fixture(scope="session")
def find_job_staff_user(is_job_staff):
def find(jobs, users, is_staff, wo_jobs=None):
for job in jobs:
if wo_jobs is not None and job["id"] in wo_jobs:
continue
for user in users:
if is_staff == is_job_staff(user["id"], job["id"]):
return user["username"], job["id"]
return None, None
return find
@pytest.fixture(scope="session")
def find_task_staff_user(is_task_staff):
def find(tasks, users, is_staff, wo_tasks=None):
for task in tasks:
if wo_tasks is not None and task["id"] in wo_tasks:
continue
for user in users:
if is_staff == is_task_staff(user["id"], task["id"]):
return user["username"], task["id"]
return None, None
return find
@pytest.fixture(scope="session")
def find_issue_staff_user(is_issue_staff, is_issue_admin):
def find(issues, users, is_staff, is_admin):
for issue in issues:
for user in users:
i_admin, i_staff = (
is_issue_admin(user["id"], issue["id"]),
is_issue_staff(user["id"], issue["id"]),
)
if (is_admin is None and (i_staff or i_admin) == is_staff) or (
is_admin == i_admin and is_staff == i_staff
):
return user["username"], issue["id"]
return None, None
return find
@pytest.fixture(scope="session")
def filter_jobs(jobs):
def filter_(**kwargs):
return filter_assets(jobs, **kwargs)
return filter_
@pytest.fixture(scope="session")
def filter_labels(labels):
def filter_(**kwargs):
return filter_assets(labels, **kwargs)
return filter_
@pytest.fixture(scope="session")
def filter_jobs_with_shapes(annotations):
def find(jobs):
return list(filter(lambda j: annotations["job"].get(str(j["id"]), {}).get("shapes"), jobs))
return find
@pytest.fixture(scope="session")
def filter_tasks_with_shapes(annotations):
def find(tasks):
return list(
filter(lambda t: annotations["task"].get(str(t["id"]), {}).get("shapes"), tasks)
)
return find
@pytest.fixture(scope="session")
def jobs_with_shapes(jobs, filter_jobs_with_shapes):
return filter_jobs_with_shapes(jobs)
@pytest.fixture(scope="session")
def tasks_with_shapes(tasks, filter_tasks_with_shapes):
return filter_tasks_with_shapes(tasks)
@pytest.fixture(scope="session")
def admin_user(users):
for user in users:
if user["is_superuser"] and user["is_active"]:
return user["username"]
raise Exception("Can't find any admin user in the test DB")
@pytest.fixture(scope="session")
def regular_user(users):
for user in users:
if not user["is_superuser"] and user["is_active"]:
return user["username"]
raise Exception("Can't find any regular user in the test DB")
@pytest.fixture(scope="session")
def regular_lonely_user(users):
for user in users:
if user["username"] == "lonely_user":
return user["username"]
raise Exception("Can't find the lonely user in the test DB")
@pytest.fixture(scope="session")
def job_has_annotations(annotations) -> bool:
def check_has_annotations(job_id: int) -> bool:
job_annotations = annotations["job"][str(job_id)]
return bool(
job_annotations["tags"] or job_annotations["shapes"] or job_annotations["tracks"]
)
return check_has_annotations
@pytest.fixture(scope="session")
def access_tokens(access_tokens_by_username):
"Private keys are available in the 'private_key' field."
return sorted(
(t for user_tokens in access_tokens_by_username.values() for t in user_tokens),
key=lambda t: t["id"],
)
@pytest.fixture(scope="session")
def raw_access_tokens_by_username():
with open(ASSETS_DIR / "access_tokens.json") as f:
return json.load(f)["user"]
@pytest.fixture(scope="session")
def access_tokens_by_username(raw_access_tokens_by_username):
"Private keys are available in the 'private_key' field."
private_keys = {
3: "XQRwNl8D.N5EYCzdyWdroeVVfJylkquAmBqgt9Kw2", # nosec
4: "waUchCLi.wWxJTdYBt6R8auMse86bwHobMomjQvEB", # nosec
5: "2HVbBoWR.ZJqJtm3TEKEkjqZwyoL7Ig71LVvKRj79", # nosec
7: "gIUANJCa.W4Y101GNS8wOyFcncvxMZjTEnU7dzAUF", # nosec
}
data = {}
for username, user_tokens in raw_access_tokens_by_username.items():
if not user_tokens:
continue
extended_user_tokens = []
for access_token in user_tokens:
access_token = access_token.copy()
access_token["private_key"] = private_keys[access_token["id"]]
extended_user_tokens.append(access_token)
data[username] = extended_user_tokens
return data
@pytest.fixture(scope="session")
def fxt_local_audio_file_path() -> Generator[Path, None, None]:
yield SHARE_DIR / "audio" / "sample1.mp3"
+707
View File
@@ -0,0 +1,707 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import logging
import os
import sys
from enum import Enum
from http import HTTPStatus
from pathlib import Path
from subprocess import PIPE, CalledProcessError, run
from time import sleep
import pytest
import requests
import yaml
from shared.utils.config import ASSETS_DIR, get_server_url
logger = logging.getLogger(__name__)
CVAT_ROOT_DIR = next(dir.parent for dir in Path(__file__).parents if dir.name == "tests")
CVAT_DB_DIR = ASSETS_DIR / "cvat_db"
CLICKHOUSE_INIT_SCRIPT = "components/analytics/clickhouse/init.py"
PREFIX = "test"
CONTAINER_NAME_FILES = ["docker-compose.tests.yml"]
DC_FILES = CONTAINER_NAME_FILES + [
"docker-compose.dev.yml",
"tests/docker-compose.file_share.yml",
"tests/docker-compose.minio.yml",
"tests/docker-compose.pat_settings.yml",
"tests/docker-compose.test_servers.yml",
]
class Container(str, Enum):
DB = "cvat_db"
SERVER = "cvat_server"
WORKER_ANNOTATION = "cvat_worker_annotation"
WORKER_IMPORT = "cvat_worker_import"
WORKER_EXPORT = "cvat_worker_export"
WORKER_QUALITY_REPORTS = "cvat_worker_quality_reports"
WORKER_WEBHOOKS = "cvat_worker_webhooks"
WORKER_UTILS = "cvat_worker_utils"
def __str__(self):
return self.value
@classmethod
def covered(cls):
return [item.value for item in cls if item != cls.DB]
def pytest_addoption(parser):
group = parser.getgroup("CVAT REST API testing options")
group._addoption(
"--start-services",
action="store_true",
help="Start all necessary CVAT containers without running tests. (default: %(default)s)",
)
group._addoption(
"--stop-services",
action="store_true",
help="Stop all testing containers without running tests. (default: %(default)s)",
)
group._addoption(
"--rebuild",
action="store_true",
help="Rebuild CVAT images and then start containers. (default: %(default)s)",
)
group._addoption(
"--cleanup",
action="store_true",
help="Delete files that was create by tests without running tests. (default: %(default)s)",
)
group._addoption(
"--dumpdb",
action="store_true",
help="Update data.json without running tests. (default: %(default)s)",
)
group._addoption(
"--keep-data",
action="store_true",
help="Do not reset volumes and database. (default: %(default)s)",
)
group._addoption(
"--no-services",
action="store_true",
help=("""Don't start, stop, or seed any containers — assume the user runs
the test stack externally"""),
)
group._addoption(
"--platform",
action="store",
default="local",
choices=("kube", "local"),
help="Platform identifier - 'kube' or 'local'. (default: %(default)s)",
)
def _run(command, capture_output=True):
_command = command.split() if isinstance(command, str) else command
logger.debug(f"Executing a command: {_command}")
if capture_output:
proc = run(_command, check=True, stdout=PIPE) # nosec
stdout = proc.stdout.decode()
else:
proc = run(_command, check=True) # nosec
stdout = ""
if stdout:
logger.debug(f"Output (stdout): {stdout}")
return stdout
def _kube_get_pod_name(label_filter):
return _run(f"kubectl get pods -l {label_filter} -o jsonpath={{.items[0].metadata.name}}")
def _kube_get_server_pod_name():
return _kube_get_pod_name("component=server")
def _kube_get_db_pod_name():
return _kube_get_pod_name("app.kubernetes.io/name=postgresql")
def _kube_get_clichouse_pod_name():
return _kube_get_pod_name("app.kubernetes.io/name=clickhouse")
def _kube_get_redis_inmem_pod_name():
return _kube_get_pod_name("app.kubernetes.io/name=redis")
def _kube_get_redis_ondisk_pod_name():
return _kube_get_pod_name("app.kubernetes.io/name=cvat,tier=kvrocks")
def docker_cp(source, target):
_run(f"docker container cp {source} {target}")
def kube_cp(source, target):
_run(f"kubectl cp {source} {target}")
def docker_exec(container, command, capture_output=True):
return _run(f"docker exec -u root {PREFIX}_{container}_1 {command}", capture_output)
def docker_exec_cvat(command: list[str] | str):
base = f"docker exec {PREFIX}_cvat_server_1"
_command = f"{base} {command}" if isinstance(command, str) else base.split() + command
return _run(_command)
def kube_exec_cvat(command: list[str] | str):
pod_name = _kube_get_server_pod_name()
base = f"kubectl exec {pod_name} --"
_command = f"{base} {command}" if isinstance(command, str) else base.split() + command
return _run(_command)
def container_exec_cvat(request: pytest.FixtureRequest, command: 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)
def docker_exec_clickhouse_db(command):
_run(["docker", "exec", f"{PREFIX}_cvat_clickhouse_1"] + command)
def kube_exec_clickhouse_db(command):
pod_name = _kube_get_clichouse_pod_name()
_run(["kubectl", "exec", pod_name, "--"] + command)
def docker_exec_redis_inmem(command):
return _run(["docker", "exec", f"{PREFIX}_cvat_redis_inmem_1"] + command)
def kube_exec_redis_inmem(command):
pod_name = _kube_get_redis_inmem_pod_name()
return _run(["kubectl", "exec", pod_name, "--"] + command)
def docker_exec_redis_ondisk(command):
_run(["docker", "exec", f"{PREFIX}_cvat_redis_ondisk_1"] + command)
def kube_exec_redis_ondisk(command):
pod_name = _kube_get_redis_ondisk_pod_name()
_run(["kubectl", "exec", pod_name, "--"] + command)
def docker_restore_db():
docker_exec(
Container.DB, "psql -U root -d postgres -v from=test_db -v to=cvat -f /tmp/restore.sql"
)
def kube_restore_db():
kube_exec_cvat_db(
[
"/bin/sh",
"-c",
"PGPASSWORD=cvat_postgresql_postgres psql -U postgres -d postgres -v from=test_db -v to=cvat -f /tmp/restore.sql",
]
)
def docker_restore_clickhouse_db():
docker_exec_cvat(
[
"/bin/sh",
"-c",
f'python "{CLICKHOUSE_INIT_SCRIPT}" --clear',
]
)
def kube_restore_clickhouse_db():
kube_exec_cvat(
[
"/bin/sh",
"-c",
f'python "{CLICKHOUSE_INIT_SCRIPT}" --clear',
]
)
def _get_redis_inmem_keys_to_keep():
return (
"rq:worker:",
"rq:workers",
"rq:scheduler_instance:",
"rq:queues:",
"cvat:applied_migrations",
"cvat:applied_migration:",
)
def docker_restore_redis_inmem():
docker_exec_redis_inmem(
[
"sh",
"-c",
'redis-cli -e --scan --pattern "*" |'
'grep -v "' + r"\|".join(_get_redis_inmem_keys_to_keep()) + '" |'
"xargs -r redis-cli -e del",
]
)
def kube_restore_redis_inmem():
kube_exec_redis_inmem(
[
"sh",
"-c",
'export REDISCLI_AUTH="${REDIS_PASSWORD}" && '
'redis-cli -e --scan --pattern "*" | '
'grep -v "' + r"\|".join(_get_redis_inmem_keys_to_keep()) + '" | '
"xargs -r redis-cli -e del",
]
)
def docker_restore_redis_ondisk():
docker_exec_redis_ondisk(["redis-cli", "-e", "-p", "6666", "flushall"])
def kube_restore_redis_ondisk():
kube_exec_redis_ondisk(
["sh", "-c", 'REDISCLI_AUTH="${CVAT_REDIS_ONDISK_PASSWORD}" redis-cli -e -p 6666 flushall']
)
def running_containers():
return [cn for cn in _run("docker ps --format {{.Names}}").split("\n") if cn]
def dump_db():
if "test_cvat_server_1" not in running_containers():
pytest.exit("CVAT is not running")
try:
run( # nosec
[
sys.executable,
str(Path(__file__).resolve().parents[1] / "utils" / "dump_test_db.py"),
"--output",
str(CVAT_DB_DIR / "data.json"),
],
check=True,
)
except CalledProcessError:
pytest.exit("Database dump failed.\n")
def create_compose_files(container_name_files):
for filename in container_name_files:
with (
open(filename.with_name(filename.name.replace(".tests", "")), "r") as dcf,
open(filename, "w") as ndcf,
):
dc_config = yaml.safe_load(dcf)
for service_name, service_config in dc_config["services"].items():
service_config.pop("container_name", None)
if service_name in (Container.SERVER, Container.WORKER_UTILS):
service_env = service_config["environment"]
service_env["DJANGO_SETTINGS_MODULE"] = "cvat.settings.testing_rest"
if service_name in Container.covered():
service_env = service_config["environment"]
service_env["COVERAGE_PROCESS_START"] = ".coveragerc"
service_config["volumes"].append(
"./tests/python/.coveragerc:/home/django/.coveragerc"
)
yaml.dump(dc_config, ndcf)
def delete_compose_files(container_name_files):
for filename in container_name_files:
filename.unlink(missing_ok=True)
def wait_for_services(num_secs: int = 300) -> None:
for i in range(num_secs):
logger.debug(f"waiting for the server to load ... ({i})")
try:
response = requests.get(get_server_url("api/server/health/", format="json"))
statuses = response.json()
logger.debug(f"server status: \n{statuses}")
if response.status_code == HTTPStatus.OK:
logger.debug("the server has finished loading!")
return
except Exception as e:
logger.debug(f"an error occurred during the server status checking: {e}")
sleep(1)
raise Exception(
f"Failed to reach the server during {num_secs} seconds. Please check the configuration."
)
def docker_restore_data_volumes():
docker_cp(
CVAT_DB_DIR / "cvat_data.tar.bz2",
f"{PREFIX}_cvat_server_1:/tmp/cvat_data.tar.bz2",
)
docker_exec_cvat("tar --strip 3 -xjf /tmp/cvat_data.tar.bz2 -C /home/django/data/")
def kube_restore_data_volumes():
pod_name = _kube_get_server_pod_name()
kube_cp(
CVAT_DB_DIR / "cvat_data.tar.bz2",
f"{pod_name}:/tmp/cvat_data.tar.bz2",
)
kube_exec_cvat("tar --strip 3 -xjf /tmp/cvat_data.tar.bz2 -C /home/django/data/")
def get_server_image_tag():
return f"cvat/server:{os.environ.get('CVAT_VERSION', 'dev')}"
def docker_compose(dc_files, cvat_root_dir):
return [
"docker",
"compose",
f"--project-name={PREFIX}",
# use compatibility mode to have fixed names for containers (with underscores)
# https://github.com/docker/compose#about-update-and-backward-compatibility
"--compatibility",
f"--env-file={cvat_root_dir / 'tests/python/webhook_receiver/.env'}",
*(f"--file={f}" for f in dc_files),
]
def start_services(dc_files, rebuild=False, cvat_root_dir=CVAT_ROOT_DIR):
if any([cn in ["cvat_server", "cvat_db"] for cn in running_containers()]):
pytest.exit(
"It's looks like you already have running cvat containers. Stop them and try again. "
f"List of running containers: {', '.join(running_containers())}"
)
_run(
docker_compose(dc_files, cvat_root_dir) + ["up", "-d", *["--build"] * rebuild],
capture_output=False,
)
def stop_services(dc_files, cvat_root_dir=CVAT_ROOT_DIR):
run(docker_compose(dc_files, cvat_root_dir) + ["down", "-v"], capture_output=False)
def session_start(
session,
cvat_root_dir=CVAT_ROOT_DIR,
cvat_db_dir=CVAT_DB_DIR,
extra_dc_files=None,
waiting_time=300,
):
stop = session.config.getoption("--stop-services")
start = session.config.getoption("--start-services")
rebuild = session.config.getoption("--rebuild")
keep_data = session.config.getoption("--keep-data")
cleanup = session.config.getoption("--cleanup")
dumpdb = session.config.getoption("--dumpdb")
if session.config.getoption("--collect-only"):
if any((stop, start, rebuild, cleanup, dumpdb)):
raise Exception("""--collect-only is not compatible with any of the other options:
--stop-services --start-services --rebuild --cleanup --dumpdb""")
return # don't need to start the services to collect tests
if session.config.getoption("--no-services"):
# User manages the stack and the server externally — pytest does
# nothing on session start. Per-test restore fixtures still docker-exec
# into the user's containers (which must be named test_<service>_1).
return
platform = session.config.getoption("--platform")
if platform == "kube" and any((stop, start, rebuild, cleanup, dumpdb)):
raise Exception("""--platform=kube is not compatible with any of the other options
--stop-services --start-services --rebuild --cleanup --dumpdb""")
if platform == "local":
local_start(
start,
stop,
dumpdb,
cleanup,
rebuild,
keep_data,
cvat_root_dir,
cvat_db_dir,
extra_dc_files,
waiting_time,
)
elif platform == "kube":
kube_start(cvat_db_dir, keep_data)
def local_start(
start,
stop,
dumpdb,
cleanup,
rebuild,
keep_data,
cvat_root_dir,
cvat_db_dir,
extra_dc_files,
waiting_time,
):
if start and stop:
raise Exception("--start-services and --stop-services are incompatible")
if dumpdb:
dump_db()
pytest.exit("data.json has been updated", returncode=0)
dc_files = [cvat_root_dir / f for f in DC_FILES]
if extra_dc_files is not None:
dc_files += extra_dc_files
container_name_files = [cvat_root_dir / f for f in CONTAINER_NAME_FILES]
if cleanup:
delete_compose_files(container_name_files)
pytest.exit("All generated test files have been deleted", returncode=0)
if not all([f.exists() for f in container_name_files]) or rebuild:
delete_compose_files(container_name_files)
create_compose_files(container_name_files)
if stop:
stop_services(dc_files, cvat_root_dir)
pytest.exit("All testing containers are stopped", returncode=0)
start_services(dc_files, rebuild, cvat_root_dir)
if not keep_data:
docker_restore_data_volumes()
docker_cp(cvat_db_dir / "restore.sql", f"{PREFIX}_cvat_db_1:/tmp/restore.sql")
docker_cp(cvat_db_dir / "data.json", f"{PREFIX}_cvat_server_1:/tmp/data.json")
wait_for_services(waiting_time)
docker_exec_cvat(
[
"sh",
"-c",
"./manage.py flush --no-input && ./manage.py loaddata_sorted /tmp/data.json",
]
)
docker_exec(
Container.DB, "psql -U root -d postgres -v from=cvat -v to=test_db -f /tmp/restore.sql"
)
if start:
pytest.exit("All necessary containers have been created and started.", returncode=0)
def kube_start(cvat_db_dir, keep_data):
if keep_data:
wait_for_services()
return
kube_restore_data_volumes()
server_pod_name = _kube_get_server_pod_name()
db_pod_name = _kube_get_db_pod_name()
kube_cp(cvat_db_dir / "restore.sql", f"{db_pod_name}:/tmp/restore.sql")
kube_cp(cvat_db_dir / "data.json", f"{server_pod_name}:/tmp/data.json")
wait_for_services()
kube_exec_cvat(
["sh", "-c", "./manage.py flush --no-input && ./manage.py loaddata_sorted /tmp/data.json"]
)
kube_exec_cvat_db(
[
"/bin/sh",
"-c",
"PGPASSWORD=cvat_postgresql_postgres psql -U postgres -d postgres -v from=cvat -v to=test_db -f /tmp/restore.sql",
]
)
def pytest_sessionstart(session: pytest.Session) -> None:
session_start(session)
def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
session_finish(session)
def session_finish(session):
if session.config.getoption("--collect-only"):
return
platform = session.config.getoption("--platform")
if platform == "local":
if os.environ.get("COVERAGE_PROCESS_START"):
collect_code_coverage_from_containers()
def collect_code_coverage_from_containers():
for container in Container.covered():
process_command = "python3"
# find process with code coverage
pid = docker_exec(container, f"pidof {process_command} -o 1")
# stop process with code coverage
docker_exec(container, f"kill -15 {pid}")
sleep(3)
# get code coverage report
docker_exec(container, "coverage combine", capture_output=False)
docker_exec(container, "coverage json", capture_output=False)
docker_cp(
f"{PREFIX}_{container}_1:home/django/coverage.json",
f"coverage_{container}.json",
)
@pytest.fixture(scope="function")
def restore_db_per_function(request):
# Note that autouse fixtures are executed first within their scope, so be aware of the order
# Pre-test DB setups (eg. with class-declared autouse setup() method) may be cleaned.
# https://docs.pytest.org/en/stable/reference/fixtures.html#autouse-fixtures-are-executed-first-within-their-scope
platform = request.config.getoption("--platform")
if platform == "local":
docker_restore_db()
else:
kube_restore_db()
@pytest.fixture(scope="class")
def restore_db_per_class(request):
platform = request.config.getoption("--platform")
if platform == "local":
docker_restore_db()
else:
kube_restore_db()
@pytest.fixture(scope="function")
def restore_cvat_data_per_function(request):
platform = request.config.getoption("--platform")
if platform == "local":
docker_restore_data_volumes()
else:
kube_restore_data_volumes()
@pytest.fixture(scope="class")
def restore_cvat_data_per_class(request):
platform = request.config.getoption("--platform")
if platform == "local":
docker_restore_data_volumes()
else:
kube_restore_data_volumes()
@pytest.fixture(scope="function")
def restore_clickhouse_db_per_function(request):
# Note that autouse fixtures are executed first within their scope, so be aware of the order
# Pre-test DB setups (eg. with class-declared autouse setup() method) may be cleaned.
# https://docs.pytest.org/en/stable/reference/fixtures.html#autouse-fixtures-are-executed-first-within-their-scope
platform = request.config.getoption("--platform")
if platform == "local":
docker_restore_clickhouse_db()
else:
kube_restore_clickhouse_db()
@pytest.fixture(scope="class")
def restore_clickhouse_db_per_class(request):
platform = request.config.getoption("--platform")
if platform == "local":
docker_restore_clickhouse_db()
else:
kube_restore_clickhouse_db()
@pytest.fixture(scope="function")
def restore_redis_inmem_per_function(request):
# Note that autouse fixtures are executed first within their scope, so be aware of the order
# Pre-test DB setups (eg. with class-declared autouse setup() method) may be cleaned.
# https://docs.pytest.org/en/stable/reference/fixtures.html#autouse-fixtures-are-executed-first-within-their-scope
platform = request.config.getoption("--platform")
if platform == "local":
docker_restore_redis_inmem()
else:
kube_restore_redis_inmem()
@pytest.fixture(scope="class")
def restore_redis_inmem_per_class(request):
platform = request.config.getoption("--platform")
if platform == "local":
docker_restore_redis_inmem()
else:
kube_restore_redis_inmem()
@pytest.fixture(scope="function")
def restore_redis_ondisk_per_function(request):
platform = request.config.getoption("--platform")
if platform == "local":
docker_restore_redis_ondisk()
else:
kube_restore_redis_ondisk()
@pytest.fixture(scope="class")
def restore_redis_ondisk_per_class(request):
platform = request.config.getoption("--platform")
if platform == "local":
docker_restore_redis_ondisk()
else:
kube_restore_redis_ondisk()
@pytest.fixture(scope="class")
def restore_redis_ondisk_after_class(request):
yield
platform = request.config.getoption("--platform")
if platform == "local":
docker_restore_redis_ondisk()
else:
kube_restore_redis_ondisk()
+27
View File
@@ -0,0 +1,27 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
from functools import partial
from pathlib import PurePosixPath
import pytest
import shared.utils.s3 as s3
@pytest.fixture
def fxt_uploaded_s3_file(request: pytest.FixtureRequest):
def upload_file(s3_client: s3.S3Client, *, path: PurePosixPath | str, data: bytes):
s3_client.create_file(
data=data,
filename=str(path),
)
request.addfinalizer(
partial(
s3_client.remove_file,
filename=str(path),
)
)
return upload_file
+39
View File
@@ -0,0 +1,39 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import io
import logging
import pytest
@pytest.fixture
def fxt_stdout(capsys):
class IoProxy(io.IOBase):
def __init__(self, capsys):
self.capsys = capsys
def getvalue(self) -> str:
capture = self.capsys.readouterr()
return capture.out
yield IoProxy(capsys)
@pytest.fixture
def fxt_logger():
logger_stream = io.StringIO()
logger = logging.Logger("test", level=logging.INFO)
logger.propagate = False
logger.addHandler(logging.StreamHandler(logger_stream))
yield logger, logger_stream
@pytest.fixture
def fxt_test_name(request: pytest.FixtureRequest):
name = request.node.name
if request.fixturename and request.fixturename != "fxt_test_name":
name += f"[{request.fixturename}]"
yield name
+3
View File
@@ -0,0 +1,3 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

Some files were not shown because too many files have changed in this diff Show More