chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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]),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
# Copyright (C) 2022 Intel Corporation
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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 didn’t 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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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) == {}
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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)))
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBPDCB76ADAgECAhQksQwFGcyVwF0+gIOPMPBB+/NjNTAFBgMrZXAwFDESMBAG
|
||||
A1UEAwwJbG9jYWxob3N0MB4XDTI0MTAyODEyMTkyNFoXDTI0MTAyOTEyMTkyNFow
|
||||
FDESMBAGA1UEAwwJbG9jYWxob3N0MCowBQYDK2VwAyEAzGOv96vkrHr0GPcWL7vN
|
||||
8mgR4XMg9ItNpJ2nbMmjYCKjUzBRMB0GA1UdDgQWBBR6Hn0aG/ZGAJjY9HIUK7El
|
||||
84qAgzAfBgNVHSMEGDAWgBR6Hn0aG/ZGAJjY9HIUK7El84qAgzAPBgNVHRMBAf8E
|
||||
BTADAQH/MAUGAytlcANBAMj2zWdIa8oOiEtUWFMv+KYf1kyP1lUnlcC2xUpOj8d3
|
||||
kRYtlRX4E7F5zzzgKgNpbanRAg72qnqPiFAFCGVAhgY=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
@@ -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"
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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() == ""
|
||||
@@ -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
Binary file not shown.
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user