chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:23 +08:00
commit 10df941303
2913 changed files with 455804 additions and 0 deletions
+98
View File
@@ -0,0 +1,98 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
from collections.abc import Generator
from contextlib import contextmanager
from pathlib import Path
import requests
from cvat_sdk.api_client import ApiClient, Configuration
from cvat_sdk.core.client import Client, Config
ROOT_DIR = next(dir.parent for dir in Path(__file__).parents if dir.name == "utils")
ASSETS_DIR = (ROOT_DIR / "assets").resolve()
SHARE_DIR = (ROOT_DIR.parents[1] / "mounted_file_share").resolve()
# Suppress the warning from Bandit about hardcoded passwords
USER_PASS = "!Q@W#E$R" # nosec
BASE_URL = "http://localhost:8080"
API_URL = BASE_URL + "/api/"
# MiniIO settings
MINIO_KEY = "minio_access_key"
MINIO_SECRET_KEY = "minio_secret_key" # nosec
MINIO_ENDPOINT_URL = "http://localhost:9000"
IMPORT_EXPORT_BUCKET_ID = 3
def _to_query_params(**kwargs):
return "&".join([f"{k}={v}" for k, v in kwargs.items()])
def get_server_url(endpoint, **kwargs):
return BASE_URL + "/" + endpoint + "?" + _to_query_params(**kwargs)
def get_api_url(endpoint, **kwargs):
return API_URL + endpoint + "?" + _to_query_params(**kwargs)
def get_method(username, endpoint, **kwargs):
return requests.get(get_api_url(endpoint, **kwargs), auth=(username, USER_PASS))
def options_method(username, endpoint, **kwargs):
return requests.options(get_api_url(endpoint, **kwargs), auth=(username, USER_PASS))
def delete_method(username, endpoint, **kwargs):
return requests.delete(get_api_url(endpoint, **kwargs), auth=(username, USER_PASS))
def patch_method(username, endpoint, data, **kwargs):
return requests.patch(get_api_url(endpoint, **kwargs), json=data, auth=(username, USER_PASS))
def post_method(username, endpoint, data, **kwargs):
return requests.post(get_api_url(endpoint, **kwargs), json=data, auth=(username, USER_PASS))
def post_files_method(username, endpoint, data, files, **kwargs):
return requests.post(
get_api_url(endpoint, **kwargs), data=data, files=files, auth=(username, USER_PASS)
)
def put_method(username, endpoint, data, **kwargs):
return requests.put(get_api_url(endpoint, **kwargs), json=data, auth=(username, USER_PASS))
def server_get(username, endpoint, **kwargs):
return requests.get(get_server_url(endpoint, **kwargs), auth=(username, USER_PASS))
def make_api_client(
user: str | None = None,
*,
password: str | None = None,
access_token: str | None = None,
) -> ApiClient:
assert (
sum([bool(access_token), bool(user)]) <= 1
), "Expected only one of the 'access_token' and 'user' args"
configuration = Configuration(host=BASE_URL)
if access_token:
configuration.access_token = access_token
else:
configuration.username = user
configuration.password = password or USER_PASS
return ApiClient(configuration=configuration)
@contextmanager
def make_sdk_client(user: str, *, password: str = None) -> Generator[Client, None, None]:
with Client(BASE_URL, config=Config(status_check_period=0.01)) as client:
client.login((user, password or USER_PASS))
yield client
+119
View File
@@ -0,0 +1,119 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
"""
This script lists resources on the CVAT server
and saves them to JSON files in the `assets` directory.
Before running it, start the test instance by running:
pytest tests/python --start-services
The script determines which endpoints to query by looking at the set of existing JSON files.
For example, if `tasks.json` exists, the script will overwrite it with output of `GET /api/tasks`.
Underscores in the file name are replaced with slashes in the URL path. If the default path for an
asset file is different, it can be overridden via the --asset-path argument.
In addition, `annotations.json` is always saved.
"""
import argparse
import json
from datetime import timezone
from http import HTTPStatus
from pathlib import Path
from typing import Any
from config import ASSETS_DIR, get_method
from dateutil.parser import ParserError, parse
def clean_list_response(data: dict[str, Any]) -> dict[str, Any]:
# truncate milliseconds to 3 digit precision to align with data.json
# "2023-03-30T09:37:31.615123Z" ->
# "2023-03-30T09:37:31.615000Z"
for result in data["results"]:
for k, v in result.items():
if not isinstance(v, str):
continue
try:
parsed_date = parse(v)
except ParserError:
continue
parsed_date = parsed_date.replace(
microsecond=parsed_date.microsecond - (parsed_date.microsecond % 1000)
)
result[k] = parsed_date.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
return data
def _parse_asset_url_path(s: str) -> tuple[str, str]:
asset_filename, url_path = s.lower().rsplit(":", maxsplit=1)
return asset_filename, url_path
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--assets-dir", type=Path, default=ASSETS_DIR)
parser.add_argument(
"--asset-path",
dest="asset_url_paths",
default=[],
action="append",
type=_parse_asset_url_path,
help="Repeatable, an override for the default inferred URL path for an asset. "
"Format: '<asset filename without extension>:<url path after api/>'",
)
args = parser.parse_args()
assets_dir: Path = args.assets_dir
asset_url_paths: dict[str, str] = dict(args.asset_url_paths)
annotations = {}
access_tokens = {"user": {}}
for dump_path in assets_dir.glob("*.json"):
asset_name = dump_path.stem
endpoint = asset_url_paths.get(asset_name, asset_name.replace("_", "/"))
if asset_name in ("annotations", "access_tokens"):
continue # this will be handled at the end
response = get_method("admin1", endpoint, page_size="all")
with open(dump_path, "w") as f:
json.dump(clean_list_response(response.json()), f, indent=2, sort_keys=True)
if endpoint in ["jobs", "tasks"]:
obj_type = endpoint.removesuffix("s")
annotations[obj_type] = {}
for obj in response.json()["results"]:
oid = obj["id"]
response = get_method("admin1", f"{endpoint}/{oid}/annotations")
if response.status_code == HTTPStatus.OK:
annotations[obj_type][oid] = response.json()
if endpoint == "users":
obj_type = endpoint.removesuffix("s")
for user in response.json()["results"]:
response = get_method(
user["username"], "auth/access_tokens", page_size=100, sort="id"
)
if response.status_code == HTTPStatus.OK:
access_tokens[obj_type][user["username"]] = response.json()["results"]
for filename, data in [
("annotations.json", annotations),
("access_tokens.json", access_tokens),
]:
with open(assets_dir / filename, "w") as f:
json.dump(data, f, indent=2, sort_keys=True)
if __name__ == "__main__":
main()
+458
View File
@@ -0,0 +1,458 @@
#!/usr/bin/env python3
#
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
"""
Dump the test CVAT database to JSON with stable ordering.
The default ``manage.py dumpdata`` output is not deterministic: record order
within a model depends on the underlying ``SELECT`` order, which can differ
between runs even when DB contents are identical. This causes spurious
diffs in ``tests/python/shared/assets/cvat_db/data.json`` after each dump,
which then need to be reviewed and reverted by hand.
This script wraps ``dumpdata`` and post-processes its output to make the
result stable:
* records are sorted alphabetically by ``(model, pk)`` (the
``loaddata_sorted`` management command reorders by FK dependency at
load time, so the on-disk order can be arbitrary);
* field keys within each record are sorted alphabetically;
* known unordered many-to-many fields (e.g. ``auth.user.groups``) are
sorted as well.
It can also preserve volatile timestamp fields (``last_login``,
``*_updated_date``, ``last_used_date`` and similar) from the existing
output file. When preservation is enabled (the default), an unchanged
record keeps its previous timestamp values, so re-dumping after a
no-op test run produces zero diff. Genuinely new records get whatever
timestamp the server wrote.
Usage:
# default: run dumpdata in the test container and write the
# normalized output to tests/python/shared/assets/cvat_db/data.json,
# preserving volatile fields from the existing file.
python tests/python/shared/utils/dump_test_db.py
# read pre-existing dump from a file (or stdin via "-") instead of
# running docker exec
python tests/python/shared/utils/dump_test_db.py --from-file dump.json
# also refresh volatile fields (don't preserve from existing output)
python tests/python/shared/utils/dump_test_db.py --refresh-volatile
# write to a custom location / stdout
python tests/python/shared/utils/dump_test_db.py -o /tmp/data.json
python tests/python/shared/utils/dump_test_db.py -o -
# extend the built-in volatile/sortable maps and dumpdata excludes
# from a JSON file (e.g. when running against a Django project that
# adds models beyond the OSS apps covered by this script or you want
# to customize the dump).
python tests/python/shared/utils/dump_test_db.py \\
--extra-config path/to/dump_extra_config.json
The extra config file has up to three optional sections:
{
"extra_volatile_fields": { "<model>": ["<field>", ...], ... },
"extra_sortable_list_fields": { "<model>": ["<field>", ...], ... },
"extra_excludes": ["<app_or_app.model>", ...]
}
Field maps union with the built-in entries per model;
``extra_excludes`` append to ``DUMPDATA_EXCLUDES`` (and are ignored
when ``--from-file`` is used, since the dump is already produced).
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from subprocess import PIPE, run
from typing import Any
DEFAULT_CONTAINER_NAME = "test_cvat_server_1"
DEFAULT_OUTPUT = Path(__file__).resolve().parents[2] / "shared" / "assets" / "cvat_db" / "data.json"
STDOUT_SENTINEL = "-"
DUMPDATA_EXCLUDES = (
"admin",
"auth.permission",
"authtoken",
"contenttypes",
"django_rq",
"sessions",
)
# Fields whose value changes on every dump even when no test-relevant state
# has changed (auto_now timestamps, last-login markers, etc.). When
# preservation is enabled the script copies these values from the existing
# data.json so that re-dumping an unchanged DB produces no diff.
VOLATILE_FIELDS: dict[str, frozenset[str]] = {
"auth.user": frozenset({"last_login"}),
"engine.profile": frozenset({"last_activity_date"}),
"quality_control.qualityreport": frozenset(
{"assignee_last_updated", "gt_last_updated", "target_last_updated"}
),
"access_tokens.accesstoken": frozenset({"last_used_date"}),
}
# Any field whose name is exactly ``updated_date`` or ends with
# ``_updated_date`` is treated as volatile for every model. ``auto_now``
# timestamps drift on every save and the exact value is not relevant for
# tests, so we don't track the per-model list of such fields explicitly.
def _is_blanket_volatile(field_name: str) -> bool:
return field_name == "updated_date" or field_name.endswith("_updated_date")
# Fields holding many-to-many references whose element order is not
# semantically meaningful. Sorting them stabilizes the dump even if the
# m2m join table returns rows in a different order between runs.
SORTABLE_LIST_FIELDS: dict[str, frozenset[str]] = {
"auth.user": frozenset({"groups", "user_permissions"}),
"auth.group": frozenset({"permissions"}),
}
# ``DjangoJSONEncoder`` truncates microseconds to milliseconds: a value
# with ``microsecond`` in [1, 999] is encoded as ``.000Z``. After
# ``loaddata`` parses that back to ``microsecond=0``, the next ``dumpdata``
# omits the fraction entirely and writes a bare ``Z`` instead, producing a
# spurious diff. Drop the redundant zero milliseconds up-front so the
# round-trip is idempotent.
ZERO_MS_PATTERN = r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\.000(Z|[+-]\d{2}:\d{2})$"
_DEFAULT = object()
def _strip_zero_milliseconds(value: Any) -> Any:
if not isinstance(value, str):
return value
return re.sub(ZERO_MS_PATTERN, r"\1\2", value)
def _pk_sort_key(pk: Any) -> tuple:
# `pk` is usually an int but can be a list of natural-key parts.
# Wrap into a tuple of (type_tag, value) so heterogeneous pks order
# consistently without TypeError.
if isinstance(pk, (int, float)):
return (0, pk)
if isinstance(pk, list):
return (1, tuple(_pk_sort_key(p) for p in pk))
return (2, str(pk))
def _normalize_record(
record: dict[str, Any],
*,
sortable_list_fields: dict[str, frozenset[str]] | Any = _DEFAULT,
) -> dict[str, Any]:
if sortable_list_fields is _DEFAULT:
sortable_list_fields = SORTABLE_LIST_FIELDS
model = record.get("model", "")
fields = record.get("fields", {})
sortable = sortable_list_fields.get(model, frozenset())
new_fields: dict[str, Any] = {}
for key in sorted(fields):
value = fields[key]
if key in sortable and isinstance(value, list):
try:
value = sorted(value, key=lambda v: json.dumps(v, sort_keys=True))
except TypeError:
pass
value = _strip_zero_milliseconds(value)
new_fields[key] = value
return {"model": model, "pk": record.get("pk"), "fields": new_fields}
def _preserve_volatile(
records: list[dict[str, Any]],
reference: list[dict[str, Any]],
*,
volatile_fields: dict[str, frozenset[str]] | Any = _DEFAULT,
) -> list[dict[str, Any]]:
if volatile_fields is _DEFAULT:
volatile_fields = VOLATILE_FIELDS
ref_index = {
(r.get("model", ""), _pk_sort_key(r.get("pk"))): r.get("fields", {}) for r in reference
}
for record in records:
model = record.get("model", "")
ref_fields = ref_index.get((model, _pk_sort_key(record.get("pk"))))
if ref_fields is None:
continue
explicit = volatile_fields.get(model, frozenset())
fields = record.get("fields", {})
for name in fields:
if name not in ref_fields:
continue
if name in explicit or _is_blanket_volatile(name):
fields[name] = ref_fields[name]
return records
def normalize(
records: list[dict[str, Any]],
*,
reference: list[dict[str, Any]] | None = None,
volatile_fields: dict[str, frozenset[str]] | Any = _DEFAULT,
sortable_list_fields: dict[str, frozenset[str]] | Any = _DEFAULT,
) -> list[dict[str, Any]]:
"""Return ``records`` sorted and field-normalized, with volatile fields
optionally preserved from ``reference``."""
if volatile_fields is _DEFAULT:
volatile_fields = VOLATILE_FIELDS
if sortable_list_fields is _DEFAULT:
sortable_list_fields = _DEFAULT
normalized = [_normalize_record(r, sortable_list_fields=sortable_list_fields) for r in records]
if reference is not None:
_preserve_volatile(normalized, reference=reference, volatile_fields=volatile_fields)
# Sort alphabetically by model name (then by pk) so the committed file
# has a layout that doesn't depend on Django's dependency sort. The
# ``loaddata_sorted`` management command reorders records by dependency
# at load time, so any total ordering is fine here.
normalized.sort(key=lambda r: (r.get("model", ""), _pk_sort_key(r.get("pk"))))
return normalized
def dump_from_container(
container: str = DEFAULT_CONTAINER_NAME,
*,
excludes: tuple[str, ...] | list[str] = DUMPDATA_EXCLUDES,
) -> list[dict[str, Any]]:
cmd = [
"docker",
"exec",
container,
"python",
"manage.py",
"dumpdata",
"--indent",
"2",
"--natural-foreign",
]
for ex in excludes:
cmd.extend(["--exclude", ex])
proc = run(cmd, check=True, stdout=PIPE) # nosec
return json.loads(proc.stdout)
EXTRA_CONFIG_KEYS = ("extra_volatile_fields", "extra_sortable_list_fields", "extra_excludes")
# Conventional Django ``app_label.modelname`` shape: lowercase identifier on
# each side, joined by a single dot. Mismatch is a warning (not an error)
# because the script doesn't load Django and can't authoritatively validate
# model names — but the convention catches typos like ``engine.Task``.
_MODEL_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$")
class _ConfigError(ValueError):
"""Raised when ``--extra-config`` content is malformed."""
def _require_non_empty_str(value: Any, where: str) -> None:
if not isinstance(value, str):
raise _ConfigError(f"{where}: expected string, got {type(value).__name__}")
if not value:
raise _ConfigError(f"{where}: expected non-empty string")
def _validate_field_map(section: Any, where: str) -> None:
if not isinstance(section, dict):
raise _ConfigError(f"{where}: expected object, got {type(section).__name__}")
for model, fields in section.items():
_require_non_empty_str(model, f"{where} key")
if not _MODEL_NAME_RE.match(model):
print(
f"warning: {where}.{model!r} does not match the conventional "
"<app_label>.<model_lowercase> shape",
file=sys.stderr,
)
if not isinstance(fields, list):
raise _ConfigError(f"{where}.{model}: expected list, got {type(fields).__name__}")
seen: set[str] = set()
for i, field in enumerate(fields):
_require_non_empty_str(field, f"{where}.{model}[{i}]")
if field in seen:
raise _ConfigError(f"{where}.{model}[{i}]: duplicate field {field!r}")
seen.add(field)
def _validate_excludes(section: Any, where: str) -> None:
if not isinstance(section, list):
raise _ConfigError(f"{where}: expected list, got {type(section).__name__}")
seen: set[str] = set()
for i, item in enumerate(section):
_require_non_empty_str(item, f"{where}[{i}]")
if item in seen:
raise _ConfigError(f"{where}[{i}]: duplicate exclude {item!r}")
seen.add(item)
def _load_extra_config(path: Path) -> dict[str, Any]:
"""Read and validate an extension config file.
See ``EXTRA_CONFIG_KEYS`` for the allowed top-level sections; missing
sections default to empty. Malformed content raises ``_ConfigError``
with a JSON-pointer-style path so the offending entry is easy to find.
"""
raw = json.loads(path.read_text())
if not isinstance(raw, dict):
raise _ConfigError(f"{path}: top-level must be an object, got {type(raw).__name__}")
unknown = sorted(set(raw) - set(EXTRA_CONFIG_KEYS))
if unknown:
raise _ConfigError(
f"{path}: unknown top-level keys: {unknown}. " f"valid keys: {list(EXTRA_CONFIG_KEYS)}"
)
extra_volatile = raw.get("extra_volatile_fields", {})
extra_sortable = raw.get("extra_sortable_list_fields", {})
extra_excludes = raw.get("extra_excludes", [])
_validate_field_map(extra_volatile, "extra_volatile_fields")
_validate_field_map(extra_sortable, "extra_sortable_list_fields")
_validate_excludes(extra_excludes, "extra_excludes")
return {
"extra_volatile_fields": extra_volatile,
"extra_sortable_list_fields": extra_sortable,
"extra_excludes": extra_excludes,
}
def _merge_field_map(
base: dict[str, frozenset[str]],
extra: dict[str, list[str]],
) -> dict[str, frozenset[str]]:
merged = dict(base)
for model, fields in extra.items():
merged[model] = merged.get(model, frozenset()) | frozenset(fields)
return merged
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument(
"-o",
"--output",
type=str,
default=str(DEFAULT_OUTPUT),
help=f'Output file (default: %(default)s). Use "{STDOUT_SENTINEL}" for stdout.',
)
parser.add_argument(
"--from-file",
type=str,
help="Read pre-existing dumpdata JSON from this file instead of running docker exec. "
'Use "-" to read from stdin.',
)
parser.add_argument(
"--container",
default=DEFAULT_CONTAINER_NAME,
help="Docker container to dump from (default: %(default)s).",
)
parser.add_argument(
"--refresh-volatile",
action="store_true",
help="Take volatile timestamp fields from the new dump instead of "
"preserving them from the existing output file.",
)
parser.add_argument(
"--extra-config",
type=Path,
help="Path to a JSON file extending the built-in volatile/sortable "
"maps and dumpdata excludes. Sections (all optional): "
"extra_volatile_fields, extra_sortable_list_fields, extra_excludes. "
"Field maps union with the built-in entries per model; "
"extra_excludes append to the built-in DUMPDATA_EXCLUDES (and are "
"ignored when --from-file is used).",
)
return parser
def main(argv: list[str] | None = None) -> int:
parsed_args = _build_parser().parse_args(argv)
if parsed_args.extra_config:
try:
extra = _load_extra_config(parsed_args.extra_config)
except _ConfigError as exc:
print(f"error: invalid --extra-config: {exc}", file=sys.stderr)
return 2
else:
extra = {
"extra_volatile_fields": {},
"extra_sortable_list_fields": {},
"extra_excludes": [],
}
volatile_fields = _merge_field_map(VOLATILE_FIELDS, extra["extra_volatile_fields"])
sortable_list_fields = _merge_field_map(
SORTABLE_LIST_FIELDS, extra["extra_sortable_list_fields"]
)
excludes = list(DUMPDATA_EXCLUDES) + [
ex for ex in extra["extra_excludes"] if ex not in DUMPDATA_EXCLUDES
]
if extra["extra_excludes"] and parsed_args.from_file:
print(
"warning: extra_excludes is ignored when reading the dump from "
"--from-file (the dump is already produced).",
file=sys.stderr,
)
if parsed_args.from_file == "-":
records = json.loads(sys.stdin.read())
elif parsed_args.from_file:
records = json.loads(Path(parsed_args.from_file).read_text())
else:
records = dump_from_container(parsed_args.container, excludes=excludes)
reference_dump: list[dict[str, Any]] | None = None
output_path: Path | None = (
None if parsed_args.output == STDOUT_SENTINEL else Path(parsed_args.output)
)
if not parsed_args.refresh_volatile and output_path is not None and output_path.exists():
reference_dump = json.loads(output_path.read_text())
normalized = normalize(
records,
reference=reference_dump,
volatile_fields=volatile_fields,
sortable_list_fields=sortable_list_fields,
)
serialized = json.dumps(normalized, indent=2, ensure_ascii=False)
if output_path is None:
print(serialized)
else:
output_path.write_text(serialized + "\n")
return 0
if __name__ == "__main__":
sys.exit(main())
+131
View File
@@ -0,0 +1,131 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import io
import subprocess
from collections.abc import Generator
from fractions import Fraction
from io import BytesIO
from pathlib import Path
import av
import av.video.reformatter
import librosa
import numpy as np
from PIL import Image
from shared.fixtures.init import get_server_image_tag
def generate_image_file(filename="image.png", size=(100, 50), color=(0, 0, 0)):
f = BytesIO()
f.name = filename
image = Image.new("RGB", size=size, color=color)
image.save(f)
f.seek(0)
return f
def generate_image_files(
count: int,
*,
prefixes: list[str] | None = None,
filenames: list[str] | None = None,
sizes: list[tuple[int, int]] | None = None,
) -> list[BytesIO]:
assert not (prefixes and filenames), "prefixes cannot be used together with filenames"
assert not prefixes or len(prefixes) == count
assert not filenames or len(filenames) == count
images = []
for i in range(count):
prefix = prefixes[i] if prefixes else ""
filename = f"{prefix}{i}.jpeg" if not filenames else filenames[i]
image = generate_image_file(
filename, color=(i, i, i), **({"size": sizes[i]}) if sizes else {}
)
images.append(image)
return images
def generate_video_file(
num_frames: int,
*,
size: tuple[int, int] = (100, 50),
invalid_keyframes: bool = False,
) -> BytesIO:
f = BytesIO()
f.name = "video.mkv"
chapters = [
{
"id": 0,
"start": 0,
"end": 100,
"time_base": Fraction(1, 1000),
"metadata": {"title": "Intro"},
}
]
with av.open(f, "w") as container:
container.set_chapters(chapters)
stream = container.add_stream("mjpeg", rate=60)
stream.width = size[0]
stream.height = size[1]
stream.color_range = av.video.reformatter.ColorRange.JPEG
for i in range(num_frames):
frame = av.VideoFrame.from_image(Image.new("RGB", size=size, color=(i, i, i)))
for packet in stream.encode(frame):
if invalid_keyframes:
# Specify pts/dts values that result in 0 valid keyframes
packet.pts = 10
packet.dts = 10
container.mux(packet)
f.seek(0)
return f
def read_video_file(file: BytesIO) -> Generator[Image.Image, None, None]:
file.seek(0)
with av.open(file) as container:
video_stream = container.streams.video[0]
for frame in container.decode(video_stream):
yield frame.to_image()
def generate_manifest(path: str) -> None:
command = [
"docker",
"run",
"--rm",
"-u",
"root:root",
"-v",
f"{path}:/local",
"--entrypoint",
"python3",
get_server_image_tag(),
"utils/dataset_manifest/create.py",
"--output-dir",
"/local",
"/local",
]
try:
subprocess.check_output(command, stderr=subprocess.PIPE)
except subprocess.CalledProcessError as e:
print(e.stderr.decode("utf-8"))
raise
def read_audio_pcm(
f: Path | io.IOBase, *, offset_ms: int = 0, rate: int = 8000
) -> tuple[np.ndarray, float]:
return librosa.load(f, mono=True, sr=rate, offset=offset_ms / 1000)
@@ -0,0 +1,220 @@
import functools
import json
from abc import ABC, abstractmethod
from contextlib import ExitStack
from http import HTTPStatus
from time import sleep
from typing import Any, TypeVar
import pytest
T = TypeVar("T")
from shared.utils.config import get_method, post_method
FILENAME_TEMPLATE = "cvat/{}/{}.zip"
EXPORT_FORMAT = "CVAT for images 1.1"
IMPORT_FORMAT = "CVAT 1.1"
def _make_custom_resource_params(resource: str, obj: str, cloud_storage_id: int) -> dict[str, Any]:
return {
"filename": FILENAME_TEMPLATE.format(obj, resource),
"location": "cloud_storage",
"cloud_storage_id": cloud_storage_id,
}
def _make_default_resource_params(resource: str, obj: str) -> dict[str, Any]:
return {
"filename": FILENAME_TEMPLATE.format(obj, resource),
}
def _make_export_resource_params(
resource: str, is_default: bool = True, **kwargs
) -> dict[str, Any]:
func = _make_default_resource_params if is_default else _make_custom_resource_params
params = func(resource, **kwargs)
if resource != "backup":
params["format"] = EXPORT_FORMAT
params["save_images"] = resource == "dataset"
return params
def _make_import_resource_params(
resource: str, is_default: bool = True, **kwargs
) -> dict[str, Any]:
func = _make_default_resource_params if is_default else _make_custom_resource_params
params = func(resource, **kwargs)
if resource != "backup":
params["format"] = IMPORT_FORMAT
return params
# FUTURE-TODO: reuse common logic from rest_api/utils
def _wait_request(
user: str,
request_id: str,
*,
sleep_interval: float = 0.1,
number_of_checks: int = 100,
):
for _ in range(number_of_checks):
sleep(sleep_interval)
response = get_method(user, f"requests/{request_id}")
assert response.status_code == HTTPStatus.OK
request_details = json.loads(response.content)
status = request_details["status"]
assert status in {"started", "queued", "finished", "failed"}
if status in {"finished", "failed"}:
return
class _CloudStorageResourceTest(ABC):
@staticmethod
@abstractmethod
def _make_client():
pass
@pytest.fixture(autouse=True)
def setup(self, admin_user: str):
self.user = admin_user
self.client = self._make_client()
self.exit_stack = ExitStack()
with self.exit_stack:
yield
def _ensure_file_created(self, func: T, storage: dict[str, Any]) -> T:
@functools.wraps(func)
def wrapper(*args, **kwargs):
filename = kwargs["filename"]
bucket = storage["resource"]
# check that file doesn't exist on the bucket
assert not self.client.file_exists(bucket=bucket, filename=filename)
func(*args, **kwargs)
# check that file exists on the bucket
assert self.client.file_exists(bucket=bucket, filename=filename)
return wrapper
def _export_resource_to_cloud_storage(
self,
obj_id: int,
obj: str,
resource: str,
*,
user: str,
_expect_status: HTTPStatus = HTTPStatus.ACCEPTED,
**kwargs,
):
# initialize the export process
response = post_method(
user,
f"{obj}/{obj_id}/{resource if resource != 'annotations' else 'dataset'}/export",
data=None,
**kwargs,
)
assert response.status_code == _expect_status
if _expect_status == HTTPStatus.FORBIDDEN:
return
rq_id = json.loads(response.content).get("rq_id")
assert rq_id, "The rq_id was not found in server request"
_wait_request(user, rq_id)
def _import_resource_from_cloud_storage(
self, url: str, *, user: str, _expect_status: HTTPStatus = HTTPStatus.ACCEPTED, **kwargs
) -> None:
response = post_method(user, url, data=None, **kwargs)
status = response.status_code
assert status == _expect_status, status
if status == HTTPStatus.FORBIDDEN:
return
rq_id = response.json().get("rq_id")
assert rq_id, "The rq_id parameter was not found in the server response"
_wait_request(user, rq_id)
def _import_annotations_from_cloud_storage(
self,
obj_id,
obj,
*,
user,
_expect_status: HTTPStatus = HTTPStatus.ACCEPTED,
_check_uploaded: bool = True,
**kwargs,
):
url = f"{obj}/{obj_id}/annotations"
self._import_resource_from_cloud_storage(
url, user=user, _expect_status=_expect_status, **kwargs
)
if _expect_status == HTTPStatus.FORBIDDEN:
return
if _check_uploaded:
response = get_method(user, url)
assert response.status_code == HTTPStatus.OK
annotations = response.json()
assert len(annotations["shapes"])
def _import_backup_from_cloud_storage(
self, obj, *, user, _expect_status: HTTPStatus = HTTPStatus.ACCEPTED, **kwargs
):
self._import_resource_from_cloud_storage(
f"{obj}/backup", user=user, _expect_status=_expect_status, **kwargs
)
def _import_dataset_from_cloud_storage(
self, obj_id, obj, *, user, _expect_status: HTTPStatus = HTTPStatus.ACCEPTED, **kwargs
):
self._import_resource_from_cloud_storage(
f"{obj}/{obj_id}/dataset", user=user, _expect_status=_expect_status, **kwargs
)
def _import_resource(self, cloud_storage: dict[str, Any], resource_type: str, *args, **kwargs):
methods = {
"annotations": self._import_annotations_from_cloud_storage,
"dataset": self._import_dataset_from_cloud_storage,
"backup": self._import_backup_from_cloud_storage,
}
org_id = cloud_storage["organization"]
if org_id:
kwargs.setdefault("org_id", org_id)
kwargs.setdefault("user", self.user)
return methods[resource_type](*args, **kwargs)
def _export_resource(self, cloud_storage: dict[str, Any], *args, **kwargs):
org_id = cloud_storage["organization"]
if org_id:
kwargs.setdefault("org_id", org_id)
kwargs.setdefault("user", self.user)
export_callback = self._ensure_file_created(
self._export_resource_to_cloud_storage, storage=cloud_storage
)
export_callback(*args, **kwargs)
self.exit_stack.callback(
self.client.remove_file,
bucket=cloud_storage["resource"],
filename=kwargs["filename"],
)
+68
View File
@@ -0,0 +1,68 @@
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
from io import BytesIO
import boto3
from botocore.exceptions import ClientError
from shared.utils.config import MINIO_ENDPOINT_URL, MINIO_KEY, MINIO_SECRET_KEY
class S3Client:
def __init__(
self, endpoint_url: str, *, access_key: str, secret_key: str, bucket: str | None = None
) -> None:
self.client = self._make_boto_client(
endpoint_url=endpoint_url, access_key=access_key, secret_key=secret_key
)
self.bucket = bucket
@staticmethod
def _make_boto_client(endpoint_url: str, *, access_key: str, secret_key: str):
s3 = boto3.resource(
"s3",
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
endpoint_url=endpoint_url,
)
return s3.meta.client
def create_file(self, filename: str, data: bytes = b"", *, bucket: str | None = None):
bucket = bucket or self.bucket
assert bucket
self.client.put_object(Body=data, Bucket=bucket, Key=filename)
def remove_file(self, filename: str, *, bucket: str | None = None):
bucket = bucket or self.bucket
assert bucket
self.client.delete_object(Bucket=bucket, Key=filename)
def file_exists(self, filename: str, *, bucket: str | None = None) -> bool:
bucket = bucket or self.bucket
assert bucket
try:
self.client.head_object(Bucket=bucket, Key=filename)
return True
except ClientError as e:
if e.response["Error"]["Code"] == "404":
return False
else:
raise
def download_fileobj(self, key: str, *, bucket: str | None = None) -> bytes:
bucket = bucket or self.bucket
assert bucket
with BytesIO() as data:
self.client.download_fileobj(Bucket=bucket, Key=key, Fileobj=data)
return data.getvalue()
def make_client(*, bucket: str | None = None) -> S3Client:
return S3Client(
endpoint_url=MINIO_ENDPOINT_URL,
access_key=MINIO_KEY,
secret_key=MINIO_SECRET_KEY,
bucket=bucket,
)