This is the multi-page printable view of this section.
Click here to print.
Return to the regular view of this page.
Recipes (examples)
Complete, copy-and-run SDK scripts grouped by domain area
Every example in this section is a complete script that uses argparse.
Pass --help to any script to see all options. The same files live in
cvat-sdk/examples/.
Shared conventions:
- Every recipe takes
--host and --token. Create a token in the CVAT UI under
Profile -> Security. Wrap values that contain URL punctuation in single quotes,
e.g. --host 'https://app.cvat.ai'. auth_profile.py and auth_cli.py show
alternative sign-in flows.
- Recipes that create resources keep them and print their ids and UI links. Pass
--cleanup to delete what the script created (never the sources it read).
- Recipes that inspect or export take an existing resource id
(
--project-id, --task-id), so they work directly against your data.
- List-valued options accept multiple values, e.g.
--labels car person.
- Missing arguments exit with a friendly message; SDK errors surface as normal
Python tracebacks.
All examples are tested in the latest SDK version.
Topics
- Authenticate —
auth_token.py, auth_profile.py, auth_cli.py
- Projects — create/list, backup, restore, dataset export
- Tasks — create from a bucket, bulk-create in a project,
inspect and export
- Jobs — list jobs, round-robin assignment, batch-advance stages
- Cloud storage — attach an S3-compatible bucket
1 - Authenticate a client
Copy-and-run auth recipes: PAT (recommended), saved profiles, and the CLI-compatible argument set
Three recipes: auth_token.py is the recommended PAT path, auth_profile.py
signs in from a saved profile with no secret in your code, and auth_cli.py
wires up the shared cvat-cli argument set (--server-host, --auth,
--profile, …) so your scripts feel like an extension of the CLI.
Connect with a Personal Access Token
Opens an authenticated client with a PAT, prints the server version, and prints
who you are — a quick sanity check any script can copy.
| Flag |
Required |
Meaning |
--host |
yes |
Server URL, e.g. 'https://app.cvat.ai' |
--token |
yes |
Token created in the CVAT UI (Profile -> Security) |
python auth_token.py --host 'https://app.cvat.ai' --token '<your token>'
The script
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
"""Connect to CVAT with a Personal Access Token (PAT) — the recommended way.
Steps:
1. Open an authenticated client.
2. Print the server version.
3. Print who you are authenticated as (a quick sanity check for scripts).
Usage (run ``python auth_token.py --help`` for the full list of options):
python auth_token.py --host 'https://app.cvat.ai' --token '<your token>'
Create a token in the CVAT UI under Profile -> Security.
"""
import argparse
from cvat_sdk import make_client
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
parser.add_argument(
"--token",
required=True,
help="Personal Access Token (create one in the CVAT UI: Profile -> Security)",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
with make_client(args.host, access_token=args.token) as client:
print("Server version:", client.get_server_version())
me = client.users.retrieve_current_user()
print(f"Authenticated as {me.username} (id={me.id})")
if __name__ == "__main__":
main()
Sign in from a saved profile
Uses a saved CLI profile so no secret lives in the code. Create a profile once
with cvat-cli; then any script can pick it by name or fall back to the
default profile.
Create a profile once:
cvat-cli --server-host 'https://app.cvat.ai' profile create --name app --set-default
| Flag |
Required |
Meaning |
--profile |
no |
Name of a saved profile; omit to use the default profile |
python auth_profile.py --profile app
python auth_profile.py # uses the default profile
The script
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
"""Authenticate without putting a token in your code: use a saved profile.
Create a profile once on the command line, then any script can use it:
cvat-cli --server-host 'https://app.cvat.ai' profile create --name app --set-default
Steps:
1. If --profile is passed, use that profile; otherwise use the default profile.
2. Print who you are authenticated as.
Usage (run ``python auth_profile.py --help`` for the full list of options):
python auth_profile.py --profile app
python auth_profile.py # uses the default profile
"""
import argparse
import sys
from cvat_sdk import make_client_from_profile
from cvat_sdk.core.auth import AuthStore
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument(
"--profile", help="name of a saved profile; omit to use the default profile"
)
return parser.parse_args()
def main() -> None:
args = parse_args()
store = AuthStore()
if args.profile:
profile = store.get_profile(args.profile)
if profile is None:
sys.exit(f"Profile {args.profile!r} not found. Create it with cvat-cli.")
print(f"Using profile {args.profile!r}")
else:
default = store.get_default_profile()
if default is None:
sys.exit(
"No default profile configured. Create one with:\n"
" cvat-cli --server-host 'https://app.cvat.ai' profile create"
" --name app --set-default"
)
name, profile = default
print(f"Using default profile {name!r}")
with make_client_from_profile(profile) as client:
me = client.users.retrieve_current_user()
print(f"Authenticated as {me.username} (id={me.id})")
if __name__ == "__main__":
main()
Build a CLI-compatible script
Reuses cvat-cli’s shared auth arguments (--server-host, --server-port,
--auth, --profile, --insecure, --organization) with
configure_client_auth_arguments, then hands the parsed namespace to
make_client_from_cli, which picks the right factory (profile / PAT / password)
from the arguments. This is the go-to pattern when your script should feel like
an extension of cvat-cli.
| Flag |
Required |
Meaning |
--server-host |
fallback |
Server URL when not using a profile |
--auth |
fallback |
USER:PASS (deprecated password sign-in) or USER — see cvat-cli |
--profile |
fallback |
Named saved profile; falls back to the default profile if no host/auth |
--insecure, --organization, --server-port |
no |
Reused from cvat-cli’s shared arg set |
Also honors CVAT_ACCESS_TOKEN / CVAT_PASSWORD environment variables the same
way cvat-cli does.
python auth_cli.py --profile app
python auth_cli.py --server-host 'https://app.cvat.ai' # uses CVAT_ACCESS_TOKEN env
python auth_cli.py --server-host 'https://app.cvat.ai' --auth me:secret
The script
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
"""Build a CLI-compatible script that reuses ``cvat-cli``'s auth argument set:
``--server-host`` / ``--server-port`` / ``--auth`` / ``--profile`` / ``--insecure`` / ``--organization``.
This is the go-to pattern when your script should feel like an extension of
``cvat-cli`` — it accepts the same flags, honors the ``CVAT_ACCESS_TOKEN`` and
``PASS`` env variables, and resolves profiles the same way (explicit
``--profile``, else the default profile if no host/auth is passed).
Steps:
1. Register the shared auth flags with ``configure_client_auth_arguments()``.
2. Add your own script-specific arguments on top.
3. Hand the parsed namespace to ``make_client_from_cli()`` to create a server API client object.
Usage (run ``python auth_cli.py --help`` for the full list of options):
python auth_cli.py --profile app
# export CVAT_ACCESS_TOKEN='<token>' # for macOS/Linux
# $env:CVAT_ACCESS_TOKEN = "<token>" # for PowerShell
python auth_cli.py --server-host 'https://app.cvat.ai'
python auth_cli.py --server-host 'https://app.cvat.ai' --auth me:secret
"""
import argparse
from cvat_sdk import make_client_from_cli
from cvat_sdk.core.auth import configure_client_auth_arguments
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
configure_client_auth_arguments(parser)
# Add your script's own arguments here, e.g.
# parser.add_argument("--task-id", type=int, required=True)
return parser.parse_args()
def main() -> None:
args = parse_args()
with make_client_from_cli(args) as client:
me = client.users.retrieve_current_user()
print(f"Authenticated as {me.username} (id={me.id})")
if __name__ == "__main__":
main()
Notes:
- Personal Access Tokens are the recommended path. Password sign-in (via
--auth USER:PASS) is a deprecated fallback that will be removed in a future release.
- Full recipes:
auth_token.py,
auth_profile.py,
auth_cli.py.
2 - Project recipes
Create/list, backup, restore, dataset export — one recipe per file
Five recipes cover the project lifecycle: project_create_and_list.py for the
common CRUD path, project_add_labels.py for extending an existing project’s
label schema, project_backup.py and project_restore.py for portable
copies, and project_export_dataset.py for dataset export (local + cloud).
For a CSV overview of a project’s jobs, see job_list.py --project-id --csv
in the job recipes.
Create, list, filter, retrieve, rename
Creates a project with labels, then lists all projects, filters by name,
retrieves by id, and renames it. Pass --cleanup to delete it at the end.
| Flag |
Required |
Meaning |
--host |
yes |
Server URL, e.g. 'https://app.cvat.ai' |
--token |
yes |
Personal Access Token |
--name |
no |
Project name (default 'Example project') |
--labels |
no |
Label names, space-separated (default car person) |
--cleanup |
no |
Delete the created project at the end |
python project_create_and_list.py --host 'https://app.cvat.ai' --token '<your token>' \
--name 'My project' --labels car person
The script
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
"""Create a project with labels, then list, filter, retrieve, and rename it.
Steps:
1. Create a project with a simple label schema.
2. List all projects visible to you (pagination is handled by the SDK).
3. Filter projects by a name substring.
4. Retrieve one project by id and read its labels.
5. Rename it.
6. Optionally delete it (--cleanup).
Usage (run ``python project_create_and_list.py --help`` for the full list of options):
python project_create_and_list.py --host 'https://app.cvat.ai' --token '<your token>' \\
--name 'My project' --labels car person
"""
import argparse
from cvat_sdk import make_client, models
from cvat_sdk.core.filters import F
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
parser.add_argument(
"--token",
required=True,
help="Personal Access Token (CVAT UI: Profile -> Security)",
)
parser.add_argument(
"--name", default="Example project", help="project name (default: '%(default)s')"
)
parser.add_argument(
"--labels",
nargs="+",
default=["car", "person"],
help="label names (default: %(default)s)",
)
parser.add_argument(
"--cleanup", action="store_true", help="delete the created project at the end"
)
return parser.parse_args()
def main() -> None:
args = parse_args()
with make_client(args.host, access_token=args.token) as client:
# 1. Create a project with labels
project = client.projects.create(
models.ProjectWriteRequest(
name=args.name,
labels=[models.PatchedLabelRequest(name=name) for name in args.labels],
)
)
print(f"Created project {project.id}: {args.host}/projects/{project.id}")
# 2. List all projects
projects = client.projects.list()
print(f"Projects visible to you: {len(projects)}")
# 3. Filter by name substring
matches = client.projects.list(filter=F.name.contains(args.name))
print(f"Projects with {args.name!r} in the name: {[p.id for p in matches]}")
# 4. Retrieve by id
fetched = client.projects.retrieve(project.id)
print(f"Project {fetched.id} labels: {[label.name for label in fetched.get_labels()]}")
# 5. Rename
renamed = fetched.update(models.PatchedProjectWriteRequest(name=f"{args.name} (renamed)"))
print(f"Renamed to: {renamed.name}")
# 6. Opt-in cleanup
if args.cleanup:
renamed.remove()
print(f"Deleted project {project.id}")
else:
print("Keeping the project; pass --cleanup to delete it")
if __name__ == "__main__":
main()
Add labels to an existing project
Adds labels — optionally with selectable attributes — to a project that
already exists. Labels that are already there are skipped, so the recipe is
safe to re-run. The tasks inside the project take their labels from the
project itself, so they all pick up the change.
| Flag |
Required |
Meaning |
--host |
yes |
Server URL |
--token |
yes |
Personal Access Token |
--project-id |
yes |
Id of the project to extend |
--labels |
yes |
Label names to add, space-separated |
--attr LABEL NAME VALUE [...] |
no |
Selectable attribute for one of the --labels; repeat for more |
python project_add_labels.py --host 'https://app.cvat.ai' --token '<your token>' \
--project-id 7 --labels car person
python project_add_labels.py --host 'https://app.cvat.ai' --token '<your token>' \
--project-id 7 --labels car --attr car color red green blue
The script
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
"""Add labels, optionally with selectable attributes, to an existing project.
Steps:
1. Retrieve the project and read the labels it already has; the requested
labels that already exist are skipped, so the script is safe to re-run.
2. Attach the --attr definitions to their new labels.
3. Send one project update with the new labels. Labels of the tasks inside
the project come from the project itself, so they all pick up the change.
Usage (run ``python project_add_labels.py --help`` for the full list of options):
python project_add_labels.py --host 'https://app.cvat.ai' --token '<your token>' \\
--project-id 7 --labels car person
python project_add_labels.py --host 'https://app.cvat.ai' --token '<your token>' \\
--project-id 7 --labels car --attr car color red green blue
"""
import argparse
from cvat_sdk import make_client, models
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
parser.add_argument(
"--token",
required=True,
help="Personal Access Token (CVAT UI: Profile -> Security)",
)
parser.add_argument(
"--project-id", type=int, required=True, help="id of an existing project, e.g. 7"
)
parser.add_argument(
"--labels", nargs="+", metavar="NAME", required=True, help="label names to add"
)
parser.add_argument(
"--attr",
nargs="+",
action="append",
default=[],
metavar=("LABEL NAME", "VALUE"),
help="selectable attribute for one of the --labels: label name, attribute "
"name, then its values (repeat --attr for more attributes)",
)
args = parser.parse_args()
for attr in args.attr:
if len(attr) < 3:
parser.error("--attr needs a label, an attribute name, and at least one value")
if attr[0] not in args.labels:
parser.error(f"--attr refers to label {attr[0]!r}, which is not in --labels")
return args
def main() -> None:
args = parse_args()
attributes_per_label: dict[str, list[models.AttributeRequest]] = {}
for label_name, attribute_name, *values in args.attr:
attributes_per_label.setdefault(label_name, []).append(
models.AttributeRequest(
name=attribute_name,
input_type=models.InputTypeEnum("select"),
values=values,
default_value=values[0],
mutable=True,
)
)
with make_client(args.host, access_token=args.token) as client:
project = client.projects.retrieve(args.project_id)
existing = {label.name for label in project.get_labels()}
new_labels = []
for name in args.labels:
if name in existing:
print(f"Label {name!r} already exists, skipping")
continue
new_labels.append(
models.PatchedLabelRequest(name=name, attributes=attributes_per_label.get(name, []))
)
if new_labels:
project.update(models.PatchedProjectWriteRequest(labels=new_labels))
print(
f"Added {len(new_labels)} labels to project {project.id}: "
f"{', '.join(label.name for label in new_labels) or '-'}"
)
print(f"Project {project.id} labels: {[label.name for label in project.get_labels()]}")
if __name__ == "__main__":
main()
Back up a project
Downloads a full project backup zip — tasks, jobs, annotations, and settings.
Pair with project_restore.py to migrate or clone.
| Flag |
Required |
Meaning |
--host |
yes |
Server URL |
--token |
yes |
Personal Access Token |
--project-id |
yes |
Id of the project to back up |
--output |
no |
Destination file (default project_<id>_backup.zip) |
python project_backup.py --host 'https://app.cvat.ai' --token '<your token>' \
--project-id 42
The script
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
"""Download a backup zip of an existing project.
A backup contains the project's tasks, jobs, annotations, and settings. Pair
this recipe with project_restore.py to migrate or clone a project.
Steps:
1. Retrieve the project by id.
2. Download its backup to --output (default: project_<id>_backup.zip).
Usage (run ``python project_backup.py --help`` for the full list of options):
python project_backup.py --host 'https://app.cvat.ai' --token '<your token>' \\
--project-id 42
"""
import argparse
from pathlib import Path
from cvat_sdk import make_client
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
parser.add_argument(
"--token",
required=True,
help="Personal Access Token (CVAT UI: Profile -> Security)",
)
parser.add_argument(
"--project-id", type=int, required=True, help="id of an existing project, e.g. 42"
)
parser.add_argument(
"--output",
type=Path,
help="destination file path (default: project_<id>_backup.zip)",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
with make_client(args.host, access_token=args.token) as client:
project = client.projects.retrieve(args.project_id)
output = args.output or Path(f"project_{project.id}_backup.zip")
project.download_backup(output)
print(f"Backed up project {project.id} to {output.resolve()}")
if __name__ == "__main__":
main()
Restore a project
Restores a project from a backup zip as a brand-new project. Pass --cleanup
to delete the restored copy afterwards — useful when validating a backup file.
| Flag |
Required |
Meaning |
--host |
yes |
Server URL |
--token |
yes |
Personal Access Token |
--backup |
yes |
Path to a project backup zip |
--cleanup |
no |
Delete the restored copy (never touches the backup file) |
python project_restore.py --host 'https://app.cvat.ai' --token '<your token>' \
--backup './project_42_backup.zip'
The script
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
"""Restore a project from a backup zip as a new project.
Pair with project_backup.py to migrate or clone a project.
Steps:
1. Restore --backup as a brand-new project.
2. Optionally delete the restored copy (--cleanup) — useful when testing a
backup file.
Usage (run ``python project_restore.py --help`` for the full list of options):
python project_restore.py --host 'https://app.cvat.ai' --token '<your token>' \\
--backup './project_42_backup.zip'
"""
import argparse
import sys
from pathlib import Path
from cvat_sdk import make_client
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
parser.add_argument(
"--token",
required=True,
help="Personal Access Token (CVAT UI: Profile -> Security)",
)
parser.add_argument("--backup", type=Path, required=True, help="path to a project backup zip")
parser.add_argument(
"--cleanup",
action="store_true",
help="delete the restored project at the end (never touches the source backup)",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
if not args.backup.is_file():
sys.exit(f"--backup {args.backup} does not exist")
with make_client(args.host, access_token=args.token) as client:
restored = client.projects.create_from_backup(args.backup)
print(f"Restored a copy as project {restored.id}: {args.host}/projects/{restored.id}")
if args.cleanup:
restored.remove()
print(f"Deleted restored project {restored.id}")
else:
print("Keeping the restored project; pass --cleanup to delete it")
if __name__ == "__main__":
main()
Export a project’s tasks individually (local + cloud)
Exports each task in a project as its own dataset, both to a local zip and
straight to a registered cloud storage. By default every task is exported;
pass --task-id to export only a specific subset. Validates the format name
against the server’s list before starting.
| Flag |
Required |
Meaning |
--host |
yes |
Server URL |
--token |
yes |
Personal Access Token |
--project-id |
yes |
Id of the project to export |
--cloud-storage-id |
yes |
Registered cloud storage id (see cloud_storage_register.py) |
--export-format |
no |
Exporter name (default 'COCO 1.0') |
--task-id |
no |
Task ids to export, space-separated (default: every task in the project) |
python project_export_dataset.py --host 'https://app.cvat.ai' --token '<your token>' \
--project-id 42 --cloud-storage-id 7 --export-format 'COCO 1.0'
The script
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
"""Export a project's tasks individually, without images, to local zips AND to
a registered cloud storage.
By default every task in the project is exported; pass --task-id to export
only a specific subset. This is the SDK-only stand-in for what could become a
bulk per-task export command in cvat-cli.
Steps:
1. Fetch the server's export format list and validate --export-format.
2. Resolve which tasks to export: --task-id filters to a subset of the
project's tasks; omit it to export every task in the project.
3. For each task: export to task_<id>_dataset.zip in the current directory,
then export the same dataset straight to the cloud storage (no local
download).
Usage (run ``python project_export_dataset.py --help`` for the full list of options):
# every task in the project
python project_export_dataset.py --host 'https://app.cvat.ai' --token '<your token>' \\
--project-id 42 --cloud-storage-id 7 --export-format 'COCO 1.0'
# only tasks 10 and 11
python project_export_dataset.py --host 'https://app.cvat.ai' --token '<your token>' \\
--project-id 42 --cloud-storage-id 7 --task-id 10 11
"""
import argparse
import sys
from pathlib import Path
from cvat_sdk import make_client
from cvat_sdk.core.proxies.types import Location
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
parser.add_argument(
"--token",
required=True,
help="Personal Access Token (CVAT UI: Profile -> Security)",
)
parser.add_argument(
"--project-id", type=int, required=True, help="id of an existing project, e.g. 42"
)
parser.add_argument(
"--cloud-storage-id",
type=int,
required=True,
help="a registered cloud storage id (see cloud_storage_register.py)",
)
parser.add_argument(
"--export-format",
default="COCO 1.0",
help="exporter name, e.g. 'COCO 1.0' (default: '%(default)s')",
)
parser.add_argument(
"--task-id",
type=int,
nargs="+",
metavar="ID",
help="export only these task ids (must belong to the project); "
"omit to export every task in the project",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
with make_client(args.host, access_token=args.token) as client:
# 1. Validate the format against the server's list.
# Low-level API: there is no high-level proxy for the format list yet.
formats, _ = client.api_client.server_api.retrieve_annotation_formats()
names = [f.name for f in formats.exporters]
if args.export_format not in names:
sys.exit(
f"Unknown export format {args.export_format!r}. Choose one of: {', '.join(names)}"
)
# 2. Resolve which tasks to export.
project = client.projects.retrieve(args.project_id)
tasks_by_id = {task.id: task for task in project.get_tasks()}
if args.task_id:
missing = [str(tid) for tid in args.task_id if tid not in tasks_by_id]
if missing:
sys.exit(f"Task id(s) {', '.join(missing)} not found in project {project.id}")
tasks = [tasks_by_id[tid] for tid in args.task_id]
else:
tasks = list(tasks_by_id.values())
if not tasks:
sys.exit(f"Project {project.id} has no tasks to export")
# 3. Export each task individually: a local zip AND straight to the cloud storage.
for task in tasks:
local_path = Path(f"task_{task.id}_dataset.zip")
task.export_dataset(
args.export_format, local_path, include_images=False, location=Location.LOCAL
)
print(f"Exported {local_path.resolve()}")
remote_name = f"task_{task.id}_dataset.zip"
task.export_dataset(
args.export_format,
remote_name,
include_images=False,
location=Location.CLOUD_STORAGE,
cloud_storage_id=args.cloud_storage_id,
)
print(f"Exported {remote_name} to cloud storage {args.cloud_storage_id}")
print(f"Exported {len(tasks)} task dataset(s) from project {project.id}")
if __name__ == "__main__":
main()
Other SDK options:
| SDK method / parameter |
What it adds |
Project.download_backup(..., lightweight=True) |
Produce a smaller backup that omits media. |
client.projects.create_from_dataset(...) |
Create a project directly from a dataset archive. |
Project.import_dataset(format_name, path) |
Import annotations/data into an existing project - the import counterpart of export_dataset. |
Project.get_annotations() |
Fetch the project’s labeled data. |
Notes:
list() returns the whole collection; pagination is handled for you.
- A project backup captures tasks, jobs, users, and settings in a single zip -
but no raw media beyond what
export_dataset would include.
- For a CSV overview of a project’s jobs (no annotation geometry), use
job_list.py --project-id <id> --csv. For an actual dataset export,
use project_export_dataset.py.
include_images=False exports annotations only and is much smaller.
- Full recipes:
project_create_and_list.py,
project_add_labels.py,
project_backup.py,
project_restore.py,
project_export_dataset.py.
3 - Task recipes
Create one task or a batch of tasks from a bucket; inspect and export existing tasks
Three recipes cover the task lifecycle: task_create_from_cloud.py creates one
task from object keys already in a registered bucket,
tasks_bulk_from_cloud.py creates a whole batch of tasks in a project from
that same bucket, and task_inspect_and_export.py inspects an existing task,
exports its dataset locally, and reports analytics from its event log.
Create a task from cloud object keys
Creates a task from images that already live in a registered bucket.
| Flag |
Required |
Meaning |
--host |
yes |
Server URL |
--token |
yes |
Personal Access Token |
--cloud-storage-id |
yes |
Registered cloud storage id (see cloud_storage_register.py) |
--cloud-keys |
yes |
Object keys in the bucket, space-separated |
--name |
no |
Task name (default 'Task from cloud storage') |
--labels |
no |
Label names, space-separated (default object) |
--cleanup |
no |
Delete the created task at the end |
python task_create_from_cloud.py --host 'https://app.cvat.ai' --token '<your token>' \
--cloud-storage-id 7 --cloud-keys 'images/0001.jpg' 'images/0002.jpg' \
--labels car person
The script
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
"""Create an annotation task from images that already live in a registered
cloud storage.
Steps:
1. Create a task whose data is a list of object keys in the bucket.
2. Print the result.
3. Optionally delete it (--cleanup).
Register a bucket first with cloud_storage_register.py to get the storage id.
Usage (run ``python task_create_from_cloud.py --help`` for the full list of options):
python task_create_from_cloud.py --host 'https://app.cvat.ai' --token '<your token>' \\
--cloud-storage-id 7 --cloud-keys 'images/0001.jpg' 'images/0002.jpg' \\
--labels car person
"""
import argparse
from cvat_sdk import make_client, models
from cvat_sdk.core.proxies.tasks import ResourceType
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
parser.add_argument(
"--token",
required=True,
help="Personal Access Token (CVAT UI: Profile -> Security)",
)
parser.add_argument(
"--cloud-storage-id",
type=int,
required=True,
help="a registered cloud storage id (see cloud_storage_register.py)",
)
parser.add_argument(
"--cloud-keys",
nargs="+",
required=True,
help="object keys in the bucket, e.g. 'images/0001.jpg' 'images/0002.jpg'",
)
parser.add_argument(
"--name",
default="Task from cloud storage",
help="task name (default: '%(default)s')",
)
parser.add_argument(
"--labels", nargs="+", default=["object"], help="label names (default: %(default)s)"
)
parser.add_argument("--cleanup", action="store_true", help="delete the created task at the end")
return parser.parse_args()
def main() -> None:
args = parse_args()
with make_client(args.host, access_token=args.token) as client:
# ResourceType.SHARE + cloud_storage_id = read images from the bucket
task = client.tasks.create_from_data(
spec=models.TaskWriteRequest(
name=args.name,
labels=[models.PatchedLabelRequest(name=name) for name in args.labels],
),
resource_type=ResourceType.SHARE,
resources=args.cloud_keys,
data_params={"cloud_storage_id": args.cloud_storage_id},
)
print(f"Created task {task.id} with {task.size} frames: {args.host}/tasks/{task.id}")
if args.cleanup:
task.remove()
print(f"Deleted task {task.id}")
else:
print("Keeping the task; pass --cleanup to delete it")
if __name__ == "__main__":
main()
Bulk-create tasks in a project from a bucket
Creates several tasks in one call, all inside the same project, each reading
its data from a registered cloud storage. Two ways to spell a task’s data,
repeatable and mixable: --task KEY[,KEY,...] lists explicit object keys (a
single key makes a video/single-image task; multiple keys make an image task
whose frames are those keys in order), and --task-pattern PATTERN makes one
task from every bucket file matching a fnmatch wildcard (e.g. 'batch_a/*.jpg'),
resolved from the bucket’s manifest instead of listing every key by hand.
Because every task belongs to the project, they share its label schema — no
--labels here.
| Flag |
Required |
Meaning |
--host |
yes |
Server URL |
--token |
yes |
Personal Access Token |
--cloud-storage-id |
yes |
Registered cloud storage id (see cloud_storage_register.py) |
--project-id |
yes |
Project the tasks are created in; supplies the labels |
--task KEY[,KEY,...] |
one of --task / --task-pattern |
One --task per task; repeat the flag for more |
--task-pattern PATTERN |
one of --task / --task-pattern |
One task per wildcard, matched via the bucket’s manifest; repeat for more |
--manifest |
no |
Manifest object key used to resolve --task-pattern (default 'manifest.jsonl') |
--name-prefix |
no |
Task-name prefix; each task is named <prefix> N (default 'Bulk task') |
--cleanup |
no |
Delete every created task at the end |
# three video tasks in project 42
python tasks_bulk_from_cloud.py --host 'https://app.cvat.ai' --token '<your token>' \
--cloud-storage-id 7 --project-id 42 \
--task 'videos/clip_01.mp4' --task 'videos/clip_02.mp4' --task 'videos/clip_03.mp4'
# two image-batch tasks in project 42
python tasks_bulk_from_cloud.py --host 'https://app.cvat.ai' --token '<your token>' \
--cloud-storage-id 7 --project-id 42 \
--task 'batch_a/img_1.jpg,batch_a/img_2.jpg' \
--task 'batch_b/img_1.jpg,batch_b/img_2.jpg'
# the same two batches, without listing every key: one task per wildcard match
python tasks_bulk_from_cloud.py --host 'https://app.cvat.ai' --token '<your token>' \
--cloud-storage-id 7 --project-id 42 --manifest manifest.jsonl \
--task-pattern 'batch_a/*.jpg' --task-pattern 'batch_b/*.jpg'
The script
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
"""Bulk-create tasks inside a project, each task's data read from a registered
cloud storage.
Two ways to spell a task's data, repeatable and mixable:
--task KEY[,KEY,...] explicit object keys, in order:
* a single key -> a video task (or single-image task);
* several keys -> an image task, in the given order.
--task-pattern PATTERN every bucket file matching a fnmatch wildcard (e.g.
'batch_a/*.jpg'), resolved from the bucket's
manifest instead of being listed one by one.
All tasks land in the same project, so they share its label schema.
Steps:
1. For each --task, create a task in --project-id from its explicit keys.
2. For each --task-pattern, create a task in --project-id from every bucket
file the wildcard matches, resolved via the bucket's manifest.
3. Print the created ids and a summary count.
4. Optionally delete every created task (--cleanup).
Register a bucket first with cloud_storage_register.py to get the storage id.
A --task-pattern also needs a manifest file already generated for the bucket -
see "How to generate manifest file" in the CVAT docs on attaching cloud storage.
Usage (run ``python tasks_bulk_from_cloud.py --help`` for the full list of options):
# three video tasks in project 42
python tasks_bulk_from_cloud.py --host 'https://app.cvat.ai' --token '<your token>' \\
--cloud-storage-id 7 --project-id 42 \\
--task 'videos/clip_01.mp4' --task 'videos/clip_02.mp4' --task 'videos/clip_03.mp4'
# two image-batch tasks in project 42
python tasks_bulk_from_cloud.py --host 'https://app.cvat.ai' --token '<your token>' \\
--cloud-storage-id 7 --project-id 42 \\
--task 'batch_a/img_1.jpg,batch_a/img_2.jpg' \\
--task 'batch_b/img_1.jpg,batch_b/img_2.jpg'
# the same two batches, without listing every key: one task per wildcard match
python tasks_bulk_from_cloud.py --host 'https://app.cvat.ai' --token '<your token>' \\
--cloud-storage-id 7 --project-id 42 --manifest manifest.jsonl \\
--task-pattern 'batch_a/*.jpg' --task-pattern 'batch_b/*.jpg'
"""
import argparse
from cvat_sdk import make_client, models
from cvat_sdk.core.proxies.tasks import ResourceType
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
parser.add_argument(
"--token",
required=True,
help="Personal Access Token (CVAT UI: Profile -> Security)",
)
parser.add_argument(
"--cloud-storage-id",
type=int,
required=True,
help="a registered cloud storage id (see cloud_storage_register.py)",
)
parser.add_argument(
"--project-id",
type=int,
required=True,
help="tasks are created in this project and inherit its labels",
)
parser.add_argument(
"--task",
dest="tasks",
action="append",
default=[],
metavar="KEY[,KEY,...]",
help="comma-separated object keys for one task; repeat for more tasks",
)
parser.add_argument(
"--task-pattern",
dest="task_patterns",
action="append",
default=[],
metavar="PATTERN",
help="one task from every bucket file matching this fnmatch wildcard "
"(e.g. 'batch_a/*.jpg'); repeat for more tasks. Needs --manifest. "
"(default: '%(default)s')",
)
parser.add_argument(
"--manifest",
default="manifest.jsonl",
help="manifest object key in the bucket, used to resolve --task-pattern "
"(default: '%(default)s')",
)
parser.add_argument(
"--name-prefix",
default="Bulk task",
help="task name prefix; each task is named '<prefix> N' (default: '%(default)s')",
)
parser.add_argument(
"--cleanup", action="store_true", help="delete every created task at the end"
)
args = parser.parse_args()
if not args.tasks and not args.task_patterns:
parser.error("at least one --task or --task-pattern is required")
return args
def main() -> None:
args = parse_args()
task_key_groups = [
[key.strip() for key in spec.split(",") if key.strip()] for spec in args.tasks
]
if any(not group for group in task_key_groups):
raise SystemExit("each --task must contain at least one non-empty key")
with make_client(args.host, access_token=args.token) as client:
created = []
for keys in task_key_groups:
# Tasks in a project inherit the project's labels — do NOT pass labels.
# ResourceType.SHARE + cloud_storage_id reads the objects from the bucket.
task = client.tasks.create_from_data(
spec=models.TaskWriteRequest(
name=f"{args.name_prefix} {len(created) + 1}", project_id=args.project_id
),
resource_type=ResourceType.SHARE,
resources=keys,
data_params={"cloud_storage_id": args.cloud_storage_id},
)
created.append(task)
print(f"Created task {task.id} ({task.size} frames): {args.host}/tasks/{task.id}")
for pattern in args.task_patterns:
# A wildcard task needs the bucket's manifest as its only resource;
# the server expands filename_pattern against it (fnmatch syntax).
# use_cache=True is required to serve data straight from the bucket.
task = client.tasks.create_from_data(
spec=models.TaskWriteRequest(
name=f"{args.name_prefix} {len(created) + 1}", project_id=args.project_id
),
resource_type=ResourceType.SHARE,
resources=[args.manifest],
data_params={
"cloud_storage_id": args.cloud_storage_id,
"use_cache": True,
"filename_pattern": pattern,
},
)
created.append(task)
print(
f"Created task {task.id} ({task.size} frames) from pattern {pattern!r}: "
f"{args.host}/tasks/{task.id}"
)
print(f"Created {len(created)} tasks in project {args.project_id}")
if args.cleanup:
for task in created:
task.remove()
print(f"Deleted {len(created)} tasks")
else:
print("Keeping the tasks; pass --cleanup to delete them")
if __name__ == "__main__":
main()
Inspect a task and export its dataset
Prints a summary of an existing task (labels, jobs, frames), exports its
dataset to a local zip, then exports the task’s event log and reports two
analytics computed from it: how many people are currently assigned to a job,
and how many jobs were rejected in review and sent back for rework.
| Flag |
Required |
Meaning |
--host |
yes |
Server URL |
--token |
yes |
Personal Access Token |
--task-id |
yes |
Id of the task to inspect and export |
--export-format |
no |
Exporter name (default 'COCO 1.0') |
python task_inspect_and_export.py --host 'https://app.cvat.ai' --token '<your token>' \
--task-id 42 --export-format 'COCO 1.0'
The script
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
"""Inspect an existing task (labels, jobs, frames), export its dataset to a
local zip, and export its event log to report quick analytics.
Steps:
1. Retrieve the task and print a summary: labels, jobs (stage/state), frames.
2. Fetch the server's export format list and validate --export-format.
3. Export the dataset to task_<id>_dataset.zip in the current directory.
4. Export the task's event log to task_<id>_events.csv and report two
analytics: how many people are currently assigned to a job, and how
many jobs were rejected in review and sent back for rework - the second
one needs the log, since a job's current state doesn't show its history.
Usage (run ``python task_inspect_and_export.py --help`` for the full list of options):
python task_inspect_and_export.py --host 'https://app.cvat.ai' --token '<your token>' \\
--task-id 42 --export-format 'COCO 1.0'
"""
import argparse
import csv
import sys
from pathlib import Path
from cvat_sdk import make_client
from cvat_sdk.core.downloading import Downloader
from cvat_sdk.core.proxies.types import Location
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
parser.add_argument(
"--token",
required=True,
help="Personal Access Token (CVAT UI: Profile -> Security)",
)
parser.add_argument(
"--task-id", type=int, required=True, help="id of an existing task, e.g. 42"
)
parser.add_argument(
"--export-format",
default="COCO 1.0",
help="exporter name, e.g. 'COCO 1.0' (default: '%(default)s')",
)
return parser.parse_args()
def count_reworks(events_path: Path) -> int:
"""Count how many times a job in the log was rejected in review, i.e. sent
back to the annotator for rework. A job's current state only shows where
it stands now, not how many times it got there, so this needs the log.
"""
with events_path.open(newline="") as f:
return sum(
1
for row in csv.DictReader(f)
if row["scope"] == "update:job"
and row["obj_name"] == "state"
and row["obj_val"] == "rejected"
)
def main() -> None:
args = parse_args()
with make_client(args.host, access_token=args.token) as client:
# 1. Inspect
task = client.tasks.retrieve(args.task_id)
jobs = task.get_jobs()
print(f"Task {task.id}: {task.name!r}, {task.size} frames")
print(f" labels: {[label.name for label in task.get_labels()]}")
for job in jobs:
print(f" job {job.id}: stage={job.stage}, state={job.state}")
# 2. Validate the export format against the server's list.
# Low-level API: there is no high-level proxy for the format list yet.
formats, _ = client.api_client.server_api.retrieve_annotation_formats()
names = [f.name for f in formats.exporters]
if args.export_format not in names:
sys.exit(
f"Unknown export format {args.export_format!r}. Choose one of: {', '.join(names)}"
)
# 3. Export the dataset to a local zip
local_path = Path(f"task_{task.id}_dataset.zip")
task.export_dataset(
args.export_format, local_path, include_images=False, location=Location.LOCAL
)
print(f"Exported {local_path.resolve()}")
# 4. Export the task's event log and report quick analytics.
events_path = Path(f"task_{task.id}_events.csv")
Downloader(client).prepare_and_download_file_from_endpoint(
client.api_client.events_api.create_export_endpoint,
events_path,
query_params={"task_id": task.id},
)
print(f"Exported {events_path.resolve()}")
assigned = {job.assignee.id for job in jobs if job.assignee}
print(f" {len(assigned)} people currently assigned, {count_reworks(events_path)} reworks")
if __name__ == "__main__":
main()
Other SDK options:
| SDK method / parameter |
What it adds |
client.tasks.create_from_data(..., resource_type=ResourceType.LOCAL | SHARE | REMOTE) |
Where resources come from: LOCAL (upload local files), SHARE (keys in a cloud storage / mounted share), REMOTE (URLs). Defaults to LOCAL. |
client.tasks.create_from_data(..., data_params={...}) |
Extra data options as a dict, e.g. image_quality (1-100), sorting_method ("lexicographical"/"natural"/"predefined"/"random"), cloud_storage_id (int). |
client.tasks.create_from_data(..., annotation_path="path.zip", annotation_format="CVAT XML 1.1") |
Upload an initial annotations file at creation. annotation_path is a str file path; annotation_format is a str, default "CVAT XML 1.1". |
client.tasks.create_from_data(..., status_check_period=<int seconds>, pbar=ProgressReporter()) |
status_check_period (int, seconds) is the upload status poll interval (defaults to Config.status_check_period); pbar is a cvat_sdk.core.progress.ProgressReporter for upload progress. |
client.tasks.list(..., search=, sort=) |
Free-text search and server-side ordering (sort), in addition to filter. |
client.tasks.create_from_backup(path) |
Recreate a task from a task backup archive. |
Task.import_annotations(format_name, path) |
Load annotations into an existing task - the import counterpart of export_dataset. |
Task.get_frame(frame_id: int, *, quality="original" | "compressed") |
Return a single frame as a file-like object (io.RawIOBase) of image bytes. quality is an optional keyword argument ("original" or "compressed"); if omitted, the server default is used. |
Task.download_frames(frame_ids: Sequence[int], outdir=".", quality="original", image_extension=None, filename_pattern="frame_{frame_id:06d}{frame_ext}") |
Save the given frames to disk under outdir. image_extension (e.g. "png") overrides the auto-detected extension; quality is "original" or "compressed". |
Task.get_meta() / Task.get_frames_info() |
Read frame count, chunk layout, and per-frame metadata. |
Task.export_dataset(..., pbar=ProgressReporter()) |
Report local-download progress (a cvat_sdk.core.progress.ProgressReporter). |
Task.export_dataset(..., status_check_period=<int seconds>) |
Poll interval (int, seconds) between server status checks; defaults to Config.status_check_period. |
Task.export_dataset(filename=<directory>) |
Pass a directory as filename for a local export and the server-generated file name is used. |
Task.export_dataset(..., location=Location.CLOUD_STORAGE, cloud_storage_id=<int>) |
Export straight to a registered cloud storage instead of downloading locally. |
client.api_client.events_api.create_export(project_id=, job_id=, user_id=, _from=, to=) |
Scope or time-bound the event-log export beyond a single task. |
Notes:
- To add a task to a project, pass
project_id in TaskWriteRequest and do
not pass labels — the task inherits the project’s label schema.
- Both cloud recipes use
ResourceType.SHARE, so the images are read from the
bucket rather than uploaded from your machine.
include_images=False exports annotations only and is much smaller.
- Pass a valid
format_name from the server’s exporter list, e.g. "COCO 1.0"
or "CVAT for images 1.1". An unknown format name is rejected by the recipe.
- Full recipes:
task_create_from_cloud.py,
tasks_bulk_from_cloud.py,
task_inspect_and_export.py.
4 - Job recipes
List a task’s or project’s jobs, round-robin unassigned jobs, batch-advance completed jobs
Three recipes: job_list.py lists a task’s or project’s jobs with optional
stage/state filters and an optional CSV report, job_assign.py round-robins
unassigned jobs across a resolved pool of users and writes a CSV report, and
job_workflow.py batch-advances every completed job at a given stage to the
next stage.
List a task’s or project’s jobs
Queries the jobs of a task or a project (pick one with --task-id or
--project-id) with optional server-side --stage / --state filters,
ordered by most recently updated. Pass --csv to also write report.csv
(project_id, project_name, task_id, task_name, job_id, stage, state, assignee, frames) into the current directory.
| Flag |
Required |
Meaning |
--host |
yes |
Server URL |
--token |
yes |
Personal Access Token |
--task-id |
one of --task-id / --project-id |
Id of the task whose jobs to list |
--project-id |
one of --task-id / --project-id |
Id of the project whose jobs to list |
--stage |
no |
Only jobs at this stage, e.g. annotation |
--state |
no |
Only jobs in this state, e.g. new |
--csv |
no |
Also write report.csv into the current directory |
python job_list.py --host 'https://app.cvat.ai' --token '<your token>' \
--task-id 42
python job_list.py --host 'https://app.cvat.ai' --token '<your token>' \
--task-id 42 --stage annotation --state new
python job_list.py --host 'https://app.cvat.ai' --token '<your token>' \
--project-id 7 --csv
The script
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
"""List the jobs of an existing task or project with their stage, state, and
assignee, optionally as a CSV report.
Steps:
1. Query jobs scoped to --task-id or --project-id, most recently updated
first. --stage / --state filter server-side, so large tasks/projects
stay cheap. The same endpoint also accepts free-text search, e.g.
search='alice'.
2. Print one row per job.
3. If --csv is passed, also write report.csv into the current directory
(project_id, project_name, task_id, task_name, job_id, stage, state,
assignee, frames).
Usage (run ``python job_list.py --help`` for the full list of options):
python job_list.py --host 'https://app.cvat.ai' --token '<your token>' \\
--task-id 42
python job_list.py --host 'https://app.cvat.ai' --token '<your token>' \\
--task-id 42 --stage annotation --state new
python job_list.py --host 'https://app.cvat.ai' --token '<your token>' \\
--project-id 7 --csv
"""
import argparse
import csv
from collections.abc import Iterable
from pathlib import Path
from cvat_sdk import make_client
from cvat_sdk.core.filters import F, all_
from cvat_sdk.core.proxies.jobs import Job
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
parser.add_argument(
"--token",
required=True,
help="Personal Access Token (CVAT UI: Profile -> Security)",
)
scope = parser.add_mutually_exclusive_group(required=True)
scope.add_argument("--task-id", type=int, help="id of an existing task, e.g. 42")
scope.add_argument("--project-id", type=int, help="id of an existing project, e.g. 7")
parser.add_argument("--stage", help="only jobs at this stage, e.g. 'annotation'")
parser.add_argument("--state", help="only jobs in this state, e.g. 'new'")
parser.add_argument(
"--csv", action="store_true", help="also write report.csv into the current directory"
)
return parser.parse_args()
def write_report(jobs: Iterable[Job], path: Path) -> None:
with path.open("w", newline="") as f:
writer = csv.writer(f)
writer.writerow(
[
"project_id",
"project_name",
"task_id",
"task_name",
"job_id",
"stage",
"state",
"assignee",
"frames",
]
)
for job in jobs:
assignee = job.assignee.username if job.assignee else ""
writer.writerow(
[
job.project_id or "",
job.project_name or "",
job.task_id,
job.task_name,
job.id,
job.stage,
job.state,
assignee,
job.frame_count,
]
)
def main() -> None:
args = parse_args()
with make_client(args.host, access_token=args.token) as client:
if args.task_id is not None:
conditions = [F.task_id == args.task_id]
scope_label = f"Task {args.task_id}"
else:
conditions = [F.project_id == args.project_id]
scope_label = f"Project {args.project_id}"
if args.stage:
conditions.append(F.stage == args.stage)
if args.state:
conditions.append(F.state == args.state)
jobs = client.jobs.list(filter=all_(*conditions), sort="-updated_date")
print(f"{scope_label}: {len(jobs)} matching jobs")
for job in jobs:
assignee = job.assignee.username if job.assignee else "-"
print(f" job {job.id}: stage={job.stage}, state={job.state}, assignee={assignee}")
if args.csv:
report_path = Path("report.csv")
write_report(jobs, report_path)
print(f"Wrote {report_path.resolve()}")
if __name__ == "__main__":
main()
Round-robin assign a task’s jobs
Distributes the unassigned jobs of a task across a resolved user pool and
writes assignments.csv (job_id, previous_assignee, new_assignee, new_assignee_id). The pool is resolved by looking up usernames exactly with
--assignees, by searching an organization’s members with --search, or
self-assigns if neither is passed.
| Flag |
Required |
Meaning |
--host |
yes |
Server URL |
--token |
yes |
Personal Access Token |
--task-id |
yes |
Id of the task |
--org SLUG |
no |
Organization slug to scope the user and job queries |
--org-id ID |
no |
Organization id, as an alternative to --org |
--assignees USERNAME [...] |
no |
Usernames to round-robin (exact match) |
--search QUERY |
no |
Search the organization’s members; every match becomes an assignee |
--assignees and --search are mutually exclusive, and so are --org and
--org-id. Omit both --assignees and --search to self-assign.
--search requires an organization, so pass it together with --org or
--org-id. Search matches the username, first_name, and last_name
fields, which is only meaningful scoped to a team.
# self-assign every unassigned job
python job_assign.py --host 'https://app.cvat.ai' --token '<your token>' \
--task-id 42
# round-robin across an explicit pool
python job_assign.py --host 'https://app.cvat.ai' --token '<your token>' \
--task-id 42 --assignees alice bob
# pool = every organization member matching the search
python job_assign.py --host 'https://app.cvat.ai' --token '<your token>' \
--task-id 42 --org 'annotators' --search 'annotator-team'
The script
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
"""Round-robin the unassigned jobs of a task across a set of annotators and
write a CSV report of the assignments (job_id, previous_assignee, new_assignee).
The user API supports server-side search within an organization, so you rarely
need to know user ids — pass usernames, or an organization and search query,
and let the recipe resolve them.
Steps:
1. Resolve the assignee pool:
--assignees USERNAME [USERNAME ...] : look up each username exactly.
--search QUERY --org SLUG : search organization members,
print the matches, use them all.
--search QUERY --org-id ID : same, using the organization id.
neither : assign to me (the authenticated user).
2. Filter the task's unassigned jobs.
3. Round-robin the jobs across the resolved users.
4. Write assignments.csv into the current directory.
Usage (run ``python job_assign.py --help`` for the full list of options):
python job_assign.py --host 'https://app.cvat.ai' --token '<your token>' \\
--task-id 42 # self-assign
python job_assign.py --host 'https://app.cvat.ai' --token '<your token>' \\
--task-id 42 --assignees alice bob
python job_assign.py --host 'https://app.cvat.ai' --token '<your token>' \\
--task-id 42 --org 'annotators' --search 'annotator-team'
# pool = matches in the organization
"""
import argparse
import csv
import sys
from pathlib import Path
from cvat_sdk import make_client, models
from cvat_sdk.core.filters import F, all_, not_
from cvat_sdk.core.proxies.users import User
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
parser.add_argument(
"--token",
required=True,
help="Personal Access Token (CVAT UI: Profile -> Security)",
)
parser.add_argument(
"--task-id", type=int, required=True, help="id of an existing task, e.g. 42"
)
organization = parser.add_mutually_exclusive_group()
organization.add_argument(
"--org", metavar="SLUG", help="organization slug to scope user and job queries"
)
organization.add_argument(
"--org-id", type=int, metavar="ID", help="organization id to scope user and job queries"
)
group = parser.add_mutually_exclusive_group()
group.add_argument(
"--assignees",
nargs="+",
metavar="USERNAME",
help="usernames to round-robin across (looked up exactly on the server)",
)
group.add_argument(
"--search",
metavar="QUERY",
help="server-side search within --org/--org-id; every matching member becomes an assignee",
)
args = parser.parse_args()
if args.search and args.org is None and args.org_id is None:
parser.error("--search requires --org or --org-id")
return args
def organization_filters(args: argparse.Namespace) -> dict[str, str | int]:
if args.org is not None:
return {"org": args.org}
if args.org_id is not None:
return {"org_id": args.org_id}
return {}
def resolve_pool(client, args: argparse.Namespace) -> list[User]:
"""Resolve --assignees / --search / nothing to a list of User objects."""
org_filters = organization_filters(args)
if args.search:
matches = client.users.list(search=args.search, **org_filters)
if not matches:
sys.exit(f"No users matched search {args.search!r}")
print(f"Users matching {args.search!r}:")
for user in matches:
print(f" {user.id}\t{user.username}")
return matches
if args.assignees:
pool: list[User] = []
for username in args.assignees:
found = client.users.list(filter=F.username == username, **org_filters)
if not found:
sys.exit(f"User {username!r} not found")
pool.append(found[0])
return pool
me = client.users.retrieve_current_user()
print(f"No --assignees / --search; self-assigning as {me.username} (id={me.id})")
return [me]
def main() -> None:
args = parse_args()
report_path = Path("assignments.csv")
with make_client(args.host, access_token=args.token) as client:
pool = resolve_pool(client, args)
unassigned = client.jobs.list(
filter=all_(F.task_id == args.task_id, not_(F.assignee.is_set())),
**organization_filters(args),
)
print(f"Task {args.task_id}: {len(unassigned)} unassigned jobs to distribute")
with report_path.open("w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["job_id", "previous_assignee", "new_assignee", "new_assignee_id"])
for i, job in enumerate(unassigned):
user = pool[i % len(pool)]
previous = job.assignee.username if job.assignee else ""
job.update(models.PatchedJobWriteRequest(assignee=user.id))
writer.writerow([job.id, previous, user.username, user.id])
print(f"Assigned job {job.id} -> {user.username} (id={user.id})")
print(f"Wrote {report_path.resolve()}")
if __name__ == "__main__":
main()
Batch-advance completed jobs
Finds every job whose state is completed at --from-stage and moves each
one to the next stage (annotation → validation → acceptance). Optionally
restrict the sweep to a single task.
| Flag |
Required |
Meaning |
--host |
yes |
Server URL |
--token |
yes |
Personal Access Token |
--from-stage |
yes |
Advance completed jobs at this stage (annotation or validation) |
--task-id |
no |
Restrict the sweep to a single task |
# send everything annotators finished into review
python job_workflow.py --host 'https://app.cvat.ai' --token '<your token>' \
--from-stage annotation
# accept everything that passed review, scoped to one task
python job_workflow.py --host 'https://app.cvat.ai' --token '<your token>' \
--from-stage validation --task-id 42
The script
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
"""Batch-advance completed jobs to the next workflow stage
Find every job whose state is 'completed' at --from-stage, move each one to
the next stage, and print the list of modified jobs. Optionally restrict the
sweep to a single task with --task-id.
Steps:
1. Query jobs matching (stage == --from-stage, state == 'completed').
2. Update each job's stage to the next one in the workflow.
3. Print the modified job ids.
Usage (run ``python job_workflow.py --help`` for the full list of options):
# Send everything annotators finished into review:
python job_workflow.py --host 'https://app.cvat.ai' --token '<your token>' \\
--from-stage annotation
# Accept everything that passed review, scoped to one task:
python job_workflow.py --host 'https://app.cvat.ai' --token '<your token>' \\
--from-stage validation --task-id 42
"""
import argparse
from cvat_sdk import make_client, models
from cvat_sdk.core.filters import F, all_
NEXT_STAGE = {"annotation": "validation", "validation": "acceptance"}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
parser.add_argument(
"--token",
required=True,
help="Personal Access Token (CVAT UI: Profile -> Security)",
)
parser.add_argument(
"--from-stage",
required=True,
choices=sorted(NEXT_STAGE),
help="advance completed jobs currently at this stage",
)
parser.add_argument(
"--task-id",
type=int,
help="restrict the sweep to a single task (default: every task you can see)",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
to_stage = NEXT_STAGE[args.from_stage]
with make_client(args.host, access_token=args.token) as client:
conditions = [F.stage == args.from_stage, F.state == "completed"]
if args.task_id is not None:
conditions.append(F.task_id == args.task_id)
jobs = client.jobs.list(filter=all_(*conditions))
print(f"Found {len(jobs)} completed jobs at stage {args.from_stage!r}")
for job in jobs:
job.update(models.PatchedJobWriteRequest(stage=to_stage))
print(f" job {job.id}: {args.from_stage} -> {to_stage}")
print(f"Moved {len(jobs)} jobs to stage {to_stage!r}")
if __name__ == "__main__":
main()
Other SDK options:
| SDK method / parameter |
What it adds |
Job.update(models.PatchedJobWriteRequest(stage=...)) |
Change a job’s stage (retrieve the job, then update). Must be one of: annotation, validation, acceptance. |
Job.update(models.PatchedJobWriteRequest(state=...)) |
Change a job’s state, must be one of these values: new, in progress, rejected, completed. |
Job.import_annotations(..., import_mode="replace" | "append") |
"replace" overwrites the job’s existing annotations (default); "append" merges the imported ones in. |
Job.import_annotations(..., conv_mask_to_poly=True | False) |
Convert imported mask annotations to polygons (bool, server default True). |
Job.import_annotations(..., pbar=ProgressReporter()) |
Report upload progress (a cvat_sdk.core.progress.ProgressReporter). |
Job.get_issues() |
Fetch the review issues raised on a job. |
Job.export_dataset(format_name, path) |
Export a single job’s dataset - the export counterpart of import_annotations. |
Job.get_frame(frame_id: int, *, quality="original" | "compressed") |
Return a single frame as a file-like object (io.RawIOBase) of image bytes. quality is an optional keyword argument ("original" or "compressed"); if omitted, the server default is used. |
Job.download_frames(frame_ids: Sequence[int], outdir=".", quality="original", image_extension=None, filename_pattern="frame_{frame_id:06d}{frame_ext}") |
Save the given frames to disk under outdir. image_extension (e.g. "png") overrides the auto-detected extension; quality is "original" or "compressed". |
Job.get_meta() / Job.get_labels() |
Read a job’s frame metadata and label schema. |
Notes:
stage is one of annotation, validation, acceptance; state is one of
new, in progress, rejected, completed.
- Jobs are created automatically with their task (controlled by
segment_size
at task creation) — you can update and assign them, but not create a job on
its own.
- CVAT has no built-in auto-assignment, so
job_assign.py is the scripted
pattern.
- Full recipes:
job_list.py,
job_assign.py,
job_workflow.py.
5 - Annotation recipes
Import annotations into a task, edit them in bulk, and aggregate annotation statistics over a project
Three recipes: task_import_annotations.py loads an annotation file into an
existing task, task_edit_annotations.py reads a task’s annotations, applies
a bulk edit, and writes it back, and project_annotation_stats.py walks a
project’s tasks and aggregates object counts per label and type into a CSV
report.
Import annotations into a task
Uploads a local annotations file (e.g., predictions of a model, or work
exported from another server) into an existing task, and shows the object
counts before and after, so you can see what the import added. The import
format is validated against the server’s importer list.
| Flag |
Required |
Meaning |
--host |
yes |
Server URL |
--token |
yes |
Personal Access Token |
--task-id |
yes |
Id of the task to import into |
--annotations-file |
yes |
File to import, e.g. 'annotations.zip' |
--import-format |
no |
Importer name (default 'COCO 1.0') |
python task_import_annotations.py --host 'https://app.cvat.ai' --token '<your token>' \
--task-id 42 --annotations-file 'annotations.zip' --import-format 'COCO 1.0'
The script
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
"""Import annotations from a local file into an existing task, e.g. to load
predictions of a model or work made on another server.
Steps:
1. Retrieve the task and count the objects it already has.
2. Fetch the server's import format list and validate --import-format.
3. Upload the annotations file and wait for the server to process it.
4. Count the objects again to show what the import added.
Usage (run ``python task_import_annotations.py --help`` for the full list of options):
python task_import_annotations.py --host 'https://app.cvat.ai' --token '<your token>' \\
--task-id 42 --annotations-file 'annotations.zip' --import-format 'COCO 1.0'
"""
import argparse
import sys
from pathlib import Path
from cvat_sdk import make_client
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
parser.add_argument(
"--token",
required=True,
help="Personal Access Token (CVAT UI: Profile -> Security)",
)
parser.add_argument(
"--task-id", type=int, required=True, help="id of an existing task, e.g. 42"
)
parser.add_argument(
"--annotations-file",
type=Path,
required=True,
help="file to import, e.g. 'annotations.zip'",
)
parser.add_argument(
"--import-format",
default="COCO 1.0",
help="importer name, e.g. 'COCO 1.0' (default: '%(default)s')",
)
return parser.parse_args()
def count_objects(task) -> int:
"""All annotation objects of a task: tags, shapes, and tracks."""
annotations = task.get_annotations()
return len(annotations.tags) + len(annotations.shapes) + len(annotations.tracks)
def main() -> None:
args = parse_args()
with make_client(args.host, access_token=args.token) as client:
task = client.tasks.retrieve(args.task_id)
print(f"Task {task.id}: {count_objects(task)} objects before import")
formats, _ = client.api_client.server_api.retrieve_annotation_formats()
names = [f.name for f in formats.importers]
if args.import_format not in names:
sys.exit(
f"Unknown import format {args.import_format!r}. Choose one of: {', '.join(names)}"
)
task.import_annotations(args.import_format, args.annotations_file)
print(f"Imported {args.annotations_file} as {args.import_format!r}")
print(f"Task {task.id}: {count_objects(task)} objects after import")
if __name__ == "__main__":
main()
Read, edit, and write back annotations
Reads all of a task’s annotations (tags, shapes, and tracks), applies one bulk
edit — move every object from one label to another (--relabel FROM TO) or
delete every object with a given label (--delete-label NAME) — and writes
the edit back with a partial update, so the untouched objects are not
re-uploaded. Prints the per-label object counts before and after.
| Flag |
Required |
Meaning |
--host |
yes |
Server URL |
--token |
yes |
Personal Access Token |
--task-id |
yes |
Id of the task to edit |
--relabel FROM TO |
one of --relabel / --delete-label |
Move all objects from label FROM to label TO |
--delete-label NAME |
one of --relabel / --delete-label |
Delete all objects with this label |
python task_edit_annotations.py --host 'https://app.cvat.ai' --token '<your token>' \
--task-id 42 --relabel 'car' 'vehicle'
python task_edit_annotations.py --host 'https://app.cvat.ai' --token '<your token>' \
--task-id 42 --delete-label 'draft'
The script
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
"""Read a task's annotations, edit them, and write the edit back: move every
object from one label to another (--relabel) or delete every object with a
given label (--delete-label).
Steps:
1. Retrieve the task and map its label names to ids.
2. Read all annotations (tags, shapes, and tracks) and count objects per label.
3. Write the edit back with a partial annotation update, so the objects that
are not affected by it are not re-uploaded.
4. Re-read the annotations and print the per-label object counts diff.
Usage (run ``python task_edit_annotations.py --help`` for the full list of options):
python task_edit_annotations.py --host 'https://app.cvat.ai' --token '<your token>' \\
--task-id 42 --relabel 'car' 'vehicle'
python task_edit_annotations.py --host 'https://app.cvat.ai' --token '<your token>' \\
--task-id 42 --delete-label 'draft'
"""
import argparse
import sys
from collections import Counter
from cvat_sdk import make_client, models
from cvat_sdk.core.proxies.annotations import AnnotationUpdateAction
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
parser.add_argument(
"--token",
required=True,
help="Personal Access Token (CVAT UI: Profile -> Security)",
)
parser.add_argument(
"--task-id", type=int, required=True, help="id of an existing task, e.g. 42"
)
action = parser.add_mutually_exclusive_group(required=True)
action.add_argument(
"--relabel",
nargs=2,
metavar=("FROM", "TO"),
help="move all objects from label FROM to label TO",
)
action.add_argument("--delete-label", metavar="NAME", help="delete all objects with this label")
return parser.parse_args()
def label_counts(annotations, label_names: dict[int, str]) -> Counter:
"""Objects per label name, over tags, shapes, and tracks alike."""
return Counter(
label_names[obj.label_id]
for obj in [*annotations.tags, *annotations.shapes, *annotations.tracks]
)
def main() -> None:
args = parse_args()
with make_client(args.host, access_token=args.token) as client:
task = client.tasks.retrieve(args.task_id)
label_ids = {label.name: label.id for label in task.get_labels()}
label_names = {id: name for name, id in label_ids.items()}
source = args.relabel[0] if args.relabel else args.delete_label
affected = set(args.relabel) if args.relabel else {source}
for name in affected:
if name not in label_ids:
sys.exit(f"Label {name!r} not found in task {task.id}")
annotations = task.get_annotations()
before = label_counts(annotations, label_names)
tags = [tag for tag in annotations.tags if tag.label_id == label_ids[source]]
shapes = [shape for shape in annotations.shapes if shape.label_id == label_ids[source]]
tracks = [track for track in annotations.tracks if track.label_id == label_ids[source]]
matched = len(tags) + len(shapes) + len(tracks)
if args.relabel:
target_id = label_ids[args.relabel[1]]
task.update_annotations(
models.PatchedLabeledDataRequest(
tags=[
models.LabeledImageRequest(**{**tag.to_dict(), "label_id": target_id})
for tag in tags
],
shapes=[
models.LabeledShapeRequest(**{**shape.to_dict(), "label_id": target_id})
for shape in shapes
],
tracks=[
models.LabeledTrackRequest(**{**track.to_dict(), "label_id": target_id})
for track in tracks
],
),
action=AnnotationUpdateAction.UPDATE,
)
print(f"Moved {matched} objects from {source!r} to {args.relabel[1]!r}")
else:
# An empty id list would make remove_annotations() drop *all* the task
# annotations, so skip the request when the label has no objects.
if matched:
task.remove_annotations(ids=[obj.id for obj in [*tags, *shapes, *tracks]])
print(f"Deleted {matched} objects with label {source!r}")
after = label_counts(task.get_annotations(), label_names)
for name in sorted(affected):
print(f" {name}: {before[name]} -> {after[name]}")
if __name__ == "__main__":
main()
Aggregate annotation statistics over a project
Walks every task of a project and counts the annotated objects per label and
per type (a shape type such as rectangle or polygon, tag, or track).
Prints a per-task breakdown with per-label project totals and writes
annotation_stats.csv into the current directory — one row per
(task, label, type).
| Flag |
Required |
Meaning |
--host |
yes |
Server URL |
--token |
yes |
Personal Access Token |
--project-id |
yes |
Id of the project to aggregate |
python project_annotation_stats.py --host 'https://app.cvat.ai' --token '<your token>' \
--project-id 7
The script
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
"""Aggregate what was annotated across a project: object counts per label and
per object type for every task, printed and written to a CSV report.
Steps:
1. Retrieve the project and its label names.
2. Walk the project's tasks and read each task's annotations.
3. Count objects per (label, type), where the type is a shape type such as
'rectangle' or 'polygon', 'tag' for tags, or 'track' for tracks.
4. Print a per-task breakdown with per-label project totals, and write
the CSV report to --output.
Usage (run ``python project_annotation_stats.py --help`` for the full list of options):
python project_annotation_stats.py --host 'https://app.cvat.ai' --token '<your token>' \\
--project-id 7 --output annotation_stats.csv
"""
import argparse
import csv
from collections import Counter
from pathlib import Path
from cvat_sdk import make_client
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
parser.add_argument(
"--token",
required=True,
help="Personal Access Token (CVAT UI: Profile -> Security)",
)
parser.add_argument(
"--project-id", type=int, required=True, help="id of an existing project, e.g. 7"
)
parser.add_argument(
"--output",
type=Path,
default=Path("annotation_stats.csv"),
help="path to write the CSV report to (default: %(default)s)",
)
return parser.parse_args()
def task_counts(task, label_names: dict[int, str]) -> Counter:
"""Objects per (label name, object type) in one task."""
annotations = task.get_annotations()
counts = Counter()
for tag in annotations.tags:
counts[(label_names[tag.label_id], "tag")] += 1
for shape in annotations.shapes:
counts[(label_names[shape.label_id], str(shape.type))] += 1
for track in annotations.tracks:
counts[(label_names[track.label_id], "track")] += 1
return counts
def main() -> None:
args = parse_args()
report_path = args.output
with make_client(args.host, access_token=args.token) as client:
project = client.projects.retrieve(args.project_id)
label_names = {label.id: label.name for label in project.get_labels()}
tasks = project.get_tasks()
label_totals = Counter()
total = 0
with report_path.open("w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["task_id", "task_name", "label", "type", "count"])
for task in tasks:
counts = task_counts(task, label_names)
print(f"Task {task.id} {task.name!r}: {sum(counts.values())} objects")
for (label, type_), count in sorted(counts.items()):
print(f" {label}/{type_}: {count}")
writer.writerow([task.id, task.name, label, type_, count])
label_totals[label] += count
total += sum(counts.values())
print(f"Project {project.id}: {total} objects across {len(tasks)} tasks")
for label, count in sorted(label_totals.items()):
print(f" {label}: {count}")
print(f"Wrote {report_path.resolve()}")
if __name__ == "__main__":
main()
Other SDK options:
| SDK method / parameter |
What it adds |
Task.import_annotations(..., import_mode="append") |
Add the imported objects to the existing annotations instead of replacing them (server support required). |
Task.import_annotations(..., conv_mask_to_poly=True) |
Convert imported masks to polygons on the fly. |
Task.import_annotations(..., pbar=ProgressReporter()) |
Report upload progress (a cvat_sdk.core.progress.ProgressReporter). |
Job.import_annotations(format_name, path) |
The same import scoped to a single job. |
Task.set_annotations(LabeledDataRequest(...)) |
Replace a task’s annotations with the given objects. |
Task.update_annotations(PatchedLabeledDataRequest(...), action=AnnotationUpdateAction.CREATE | UPDATE | DELETE) |
Partial update: create, update, or delete only the objects in the request. |
Task.remove_annotations(ids=[...]) |
Delete specific objects by id — or all of them when ids is omitted. |
Project.get_annotations() |
Read the annotations of every task in a project in one call. |
Notes:
6 - Cloud storage recipes
Attach an S3-compatible bucket to CVAT via the low-level cloud storages API
One recipe: cloud_storage_register.py registers an S3-compatible bucket
(AWS S3, MinIO, DigitalOcean Spaces, …) as a CVAT cloud storage. It uses the
low-level client.api_client.cloudstorages_api because there is no high-level
proxy for cloud storages yet.
Attach a bucket to CVAT
Registers a bucket by key/secret, lists all registered storages, retrieves the
new one, lists the bucket’s actual content, and renames it — a smoke test that
the credentials work.
| Flag |
Required |
Meaning |
--host |
yes |
Server URL |
--token |
yes |
Personal Access Token |
--bucket |
yes |
Bucket name |
--access-key |
yes |
Bucket access key id |
--secret-key |
yes |
Bucket secret key |
--endpoint-url |
yes |
Endpoint URL, e.g. 'https://s3.amazonaws.com' |
--page-size |
no |
Entries per bucket listing request (default: the server maximum, 500) |
--cleanup |
no |
Detach the bucket from CVAT at the end (data untouched) |
python cloud_storage_register.py --host 'https://app.cvat.ai' --token '<your token>' \
--bucket 'my-bucket' --access-key '<key>' --secret-key '<secret>' \
--endpoint-url 'https://s3.amazonaws.com'
The script
# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
"""Attach an S3-compatible bucket to CVAT as a cloud storage, then list,
retrieve, and update it. Any S3-compatible service works (AWS S3, minio, ...)
via the AWS_S3_BUCKET provider and a custom endpoint URL.
There is no high-level proxy for cloud storages yet, so this recipe uses the
low-level API (client.api_client.cloudstorages_api).
Steps:
1. Attach the bucket with key/secret credentials to CVAT.
2. List all registered storages.
3. Retrieve the new one.
4. List the bucket's content, a page at a time.
5. Update its display name.
6. Optionally, detach it from CVAT.
Usage (run ``python cloud_storage_register.py --help`` for the full list of options):
python cloud_storage_register.py --host 'https://app.cvat.ai' --token '<your token>' \\
--bucket 'my-bucket' --access-key '<key>' --secret-key '<secret>' \\
--endpoint-url 'https://s3.amazonaws.com'
"""
import argparse
from cvat_sdk import make_client, models
from cvat_sdk.core.helpers import get_paginated_collection
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
parser.add_argument(
"--token",
required=True,
help="Personal Access Token (CVAT UI: Profile -> Security)",
)
parser.add_argument("--bucket", required=True, help="the bucket name, e.g. 'my-bucket'")
parser.add_argument("--access-key", required=True, help="the bucket's access key id")
parser.add_argument("--secret-key", required=True, help="the bucket's secret key")
parser.add_argument(
"--endpoint-url",
required=True,
help="e.g. 'https://s3.amazonaws.com' or 'http://minio:9000'",
)
parser.add_argument(
"--page-size",
type=int,
help="entries to fetch per bucket listing request (default: the server's "
"maximum, 500); a small value makes the pagination loop visible",
)
parser.add_argument(
"--cleanup",
action="store_true",
help="detach the storage at the end (data is never touched)",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
with make_client(args.host, access_token=args.token) as client:
api = client.api_client.cloudstorages_api
# 1. Register
storage, _ = api.create(
models.CloudStorageWriteRequest(
provider_type="AWS_S3_BUCKET", # any S3-compatible service
resource=args.bucket,
display_name=args.bucket,
credentials_type="KEY_SECRET_KEY_PAIR",
key=args.access_key,
secret_key=args.secret_key,
specific_attributes=f"endpoint_url={args.endpoint_url}",
)
)
print(f"Registered cloud storage {storage.id} -> {args.bucket}")
# 2. List — api.list() returns a single page. Pair it with
# get_paginated_collection to walk every page of any low-level list
# endpoint (works for tasks_api.list_endpoint, jobs_api.list_endpoint, ...).
storages = get_paginated_collection(api.list_endpoint)
print(f"Registered storages: {[cs.id for cs in storages]}")
# 3. Retrieve — credentials are never returned, only metadata
fetched, _ = api.retrieve(storage.id)
print(f"Storage {fetched.id}: {fetched.display_name!r} ({fetched.provider_type})")
# 4. List the bucket's content, a page at a time via next_token.
page_params = {"page_size": args.page_size} if args.page_size else {}
files = []
pages = 0
next_token = None
while True:
content, _ = api.retrieve_content_v2(
storage.id,
**page_params,
**({"next_token": next_token} if next_token else {}),
)
files.extend(content.content)
pages += 1
if not content.next:
break
next_token = content.next
print(f"Bucket {args.bucket!r} contains {len(files)} entries in {pages} page(s):")
for f in files:
print(f" {f.type.value:>3} {f.name}")
# 5. Update the display name (PATCH — only the passed fields change)
updated, _ = api.partial_update(
storage.id,
patched_cloud_storage_write_request=models.PatchedCloudStorageWriteRequest(
display_name=f"{args.bucket} (updated)"
),
)
print(f"Renamed storage {updated.id} to {updated.display_name!r}")
# 6. Opt-in cleanup: detaches the bucket from CVAT, never deletes data
if args.cleanup:
api.destroy(storage.id)
print(f"Deleted cloud storage {storage.id}")
else:
print("Keeping the storage; pass --cleanup to delete it")
if __name__ == "__main__":
main()
Other SDK options:
The recipe uses the low-level client.api_client.cloudstorages_api because
there is no high-level proxy for cloud storages yet.
| SDK method / parameter |
What it adds |
models.CloudStorageWriteRequest(description=...) |
Free-text description shown alongside the storage. |
models.CloudStorageWriteRequest(manifests=[...]) |
Attach manifest files so CVAT can index large buckets faster. |
CloudStorageWriteRequest(session_token=..., connection_string=..., account_name=...) |
Alternative credential fields for other providers (e.g. Azure, temporary S3 sessions). |
cloudstorages_api.retrieve_status(id=...) |
Check whether a registered storage is reachable/healthy. |
cloudstorages_api.retrieve_actions(id: int) |
Return the operations the credentials allow on the bucket (e.g. "read" / "read,write") as a string. id is the cloud storage id; the string is the returned data (first tuple element). |
cloudstorages_api.retrieve_content_v2(id, prefix=..., manifest_path=..., page_size=...) |
List the bucket’s actual files/directories. prefix filters to one “directory”; manifest_path lists from a manifest instead of a live bucket scan (faster for large buckets). |
cloudstorages_api.retrieve_preview(id: int) |
Fetch a preview image for the storage. id is the cloud storage id; the image bytes are on the HTTP response (response.data, the second tuple element), not the parsed data. |
PatchedCloudStorageWriteRequest(key=..., secret_key=...) |
Rotate credentials through partial_update (any writable field can be patched). |
get_paginated_collection(api.list_endpoint) |
Walk every page of any low-level *_api.list_endpoint (tasks, jobs, cloud storages, …); returns a flat list. |
Notes:
- The server validates the bucket by connecting to
endpoint_url itself, so
use an address the server container can reach.
- Cleanup detaches the bucket from CVAT; the bucket’s contents are never
touched.
- Full recipe:
cloud_storage_register.py.
7 - Webhook recipes
Register a webhook for task events and monitor task status changes live with a local receiver
Two recipes: webhook_register.py creates a webhook for task events on a
project or an organization, pings it, and summarizes its recorded deliveries;
webhook_monitor.py is the receiving side — it runs a local HTTP server,
registers a webhook pointing at it, verifies each delivery’s signature, and
aggregates task status changes live.
CVAT signs every delivery with the webhook secret: the X-Signature-256
header carries sha256=<HMAC-SHA256 of the request body>. A receiver that
recomputes and compares the signature (as webhook_monitor.py does) can be
sure the payload came from the server and not from someone who merely knows
the URL.
Register a webhook and inspect its deliveries
Creates a webhook scoped to a project (--project-id) or a whole organization
(--org), sends a test ping, then lists all recorded deliveries with
get_paginated_collection() and prints how many there are per HTTP status.
There is no high-level proxy for webhooks yet, so the recipe shows the
low-level client.api_client.webhooks_api.
| Flag |
Required |
Meaning |
--host |
yes |
Server URL |
--token |
yes |
Personal Access Token |
--project-id |
one of --project-id / --org |
Watch one project |
--org SLUG |
one of --project-id / --org |
Watch a whole organization |
--target-url |
yes |
Where the server delivers the events |
--secret |
yes |
Secret the server signs the deliveries with |
--events |
no |
Events to subscribe to (default: create:task update:task delete:task) |
--cleanup |
no |
Delete the created webhook at the end |
python webhook_register.py --host 'https://app.cvat.ai' --token '<your token>' \
--project-id 7 --target-url 'https://ci.example.com/cvat-events' --secret 'w3bh00k'
python webhook_register.py --host 'https://app.cvat.ai' --token '<your token>' \
--org 'annotators' --target-url 'https://ci.example.com/cvat-events' --secret 'w3bh00k'
The script
Monitor task status changes live
Starts a local HTTP server on --port, registers a project webhook targeting
--public-url (how the CVAT server reaches this machine — a public IP, a DNS
name, or a tunnel), and then, for every delivery: verifies the signature,
tallies the event, and for task updates prints and tallies the status change.
On Ctrl-C — or after --max-events verified events — it prints the tallies.
| Flag |
Required |
Meaning |
--host |
yes |
Server URL |
--token |
yes |
Personal Access Token |
--project-id |
yes |
Project whose task events to monitor |
--public-url |
yes |
URL under which the CVAT server can reach this machine |
--port |
no |
Local port to listen on (default 8000) |
--secret |
yes |
Secret the server signs the deliveries with |
--max-events |
no |
Stop after this many verified events (default: run until Ctrl-C) |
--cleanup |
no |
Delete the created webhook at the end |
python webhook_monitor.py --host 'https://app.cvat.ai' --token '<your token>' \
--project-id 7 --public-url 'https://my-tunnel.example.com/payload' \
--port 8000 --secret 'w3bh00k'
The script
Other SDK options:
| SDK method / parameter |
What it adds |
webhooks_api.list(project_id=, target_url=, type=, ...) |
Filter the webhook list server-side. |
webhooks_api.retrieve_events() |
The full list of event names a webhook can subscribe to. |
webhooks_api.create_deliveries_redelivery(id, delivery_id) |
Re-send a failed delivery. |
webhooks_api.partial_update(id, patched_webhook_write_request=...) |
Change a webhook’s target, events, or active state in place. |
WebhookWriteRequest(..., is_active=False) |
Create a webhook disabled, to be enabled later. |
WebhookWriteRequest(..., enable_ssl=False) |
Skip TLS certificate verification for self-signed receivers. |
Notes:
- Webhook payloads carry the event name (e.g.
update:task), the serialized
resource, the sender, and — for updates — before_update/changes with
the old field values.
- An organization webhook lives in the organization’s scope, so every call
about it must be made in that organization’s context
(
client.organization_context(slug)).
- The delivery list is paginated like every list endpoint;
get_paginated_collection() walks all the pages.
- Full recipes:
webhook_register.py,
webhook_monitor.py.