Files
Andrei Strongin c18932245f Add support for profile create --file, add documentation (#10896)
<!-- Raise an issue to propose your change
(https://github.com/cvat-ai/cvat/issues).
It helps to avoid duplication of efforts from multiple independent
contributors.
Discuss your ideas with maintainers to be sure that changes will be
approved and merged.
Read the [Contribution guide](https://docs.cvat.ai/docs/contributing/).
-->

<!-- Provide a general summary of your changes in the Title above -->

### Motivation and context
This PR adds file-based profile creation:

- `cvat-cli profile create --file <path>`
- Supports plain-text PAT files
- Supports JSON envelopes with `token`, optional `server`, and optional
`name`
- Explicit `--name` and `--server-host` override envelope values
- A complete JSON envelope can create a profile without extra CLI
arguments

It also includes:

- Tests for plain-file import, JSON import, and precedence rules
- CLI/SDK/profile documentation updates
- A changelog entry for `--file`

### How has this been tested?
<!-- Please describe in detail how you tested your changes.
Include details of your testing environment, and the tests you ran to
see how your change affects other areas of the code, etc. -->

### Checklist
<!-- Go over all the following points, and put an `x` in all the boxes
that apply.
If an item isn't applicable for some reason, then ~~explicitly
strikethrough~~ the whole
line. If you don't do that, GitHub will show incorrect progress for the
pull request.
If you're unsure about any of these, don't hesitate to ask. We're here
to help! -->
- [x] I submit my changes into the `develop` branch
- [x] I have created a changelog fragment <!-- see top comment in
CHANGELOG.md -->
- [x] I have updated the documentation accordingly
- [x] I have added tests to cover my changes
- [x] I have linked related issues (see [GitHub docs](

https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword))

### License

- [x] I submit _my code changes_ under the same [MIT License](
https://github.com/cvat-ai/cvat/blob/develop/LICENSE) that covers the
project.
  Feel free to contact the maintainers if that's a concern.

---------

Co-authored-by: Maxim Zhiltsov <maxim@cvat.ai>
2026-08-03 14:44:57 +04:00

484 lines
16 KiB
Python

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import json
import pytest
from cvat_sdk.core.auth import AuthStore, ProfileEntry
from .util import TestCliBase, run_cli
@pytest.fixture
def store_path(tmp_path, monkeypatch):
from cvat_cli.__main__ import logger
original_handlers = logger.handlers.copy()
original_level = logger.level
logger.handlers.clear()
try:
path = tmp_path / "cvat" / "auth.json"
monkeypatch.setattr("cvat_sdk.core.auth.get_auth_store_path", lambda: path)
yield path
finally:
logger.handlers[:] = original_handlers
logger.setLevel(original_level)
def _seed(path, name, server, token, *, default=False):
AuthStore(path=path).put_profile(
name,
ProfileEntry(server=server, token=token, created_date="2026-01-01T00:00:00+00:00"),
set_default=default,
)
class TestConfigCommands:
def test_default_server_set_print_unset(self, store_path, capsys):
run_cli(self, "config", "default-server", "https://app.cvat.ai")
assert AuthStore(path=store_path).get_default_server() == "https://app.cvat.ai"
capsys.readouterr()
run_cli(self, "config", "default-server")
assert "https://app.cvat.ai" in capsys.readouterr().out
run_cli(self, "config", "default-server", "--unset")
assert AuthStore(path=store_path).get_default_server() is None
def test_default_server_rejects_empty_value(self, store_path):
run_cli(self, "config", "default-server", "", expected_code=1)
assert AuthStore(path=store_path).get_default_server() is None
class TestProfileList:
def test_list_marks_default(self, store_path, capsys):
_seed(store_path, "mycvat", "https://app.cvat.ai", "t1", default=True)
_seed(store_path, "staging", "https://staging.example.com", "t2")
run_cli(self, "profile", "list")
out = capsys.readouterr().out
assert "mycvat" in out and "https://app.cvat.ai" in out and "(default)" in out
assert "staging" in out and "https://staging.example.com" in out
default_lines = [ln for ln in out.splitlines() if "(default)" in ln]
assert len(default_lines) == 1 and "mycvat" in default_lines[0]
def test_list_names_only_prints_names_only(self, store_path, capsys):
_seed(store_path, "mycvat", "https://app.cvat.ai", "t1", default=True)
run_cli(self, "profile", "list", "--names-only")
assert capsys.readouterr().out.strip() == "mycvat"
def test_list_empty_prints_nothing(self, store_path, capsys):
run_cli(self, "profile", "list")
assert capsys.readouterr().out == ""
class TestProfileDefault:
def test_print_set_unset(self, store_path, capsys):
_seed(store_path, "mycvat", "https://app.cvat.ai", "t1", default=True)
_seed(store_path, "staging", "https://staging.example.com", "t2")
run_cli(self, "profile", "default")
assert capsys.readouterr().out.strip() == "mycvat"
run_cli(self, "profile", "default", "staging")
assert AuthStore(path=store_path).get_default_profile()[0] == "staging"
run_cli(self, "profile", "default", "--unset")
assert AuthStore(path=store_path).get_default_profile() is None
def test_set_unknown_profile_errors(self, store_path, capsys):
_seed(store_path, "mycvat", "https://app.cvat.ai", "t1", default=True)
run_cli(self, "profile", "default", "ghost", expected_code=1)
assert "Unknown profile 'ghost'. Run 'cvat-cli profile list'." in capsys.readouterr().err
assert AuthStore(path=store_path).get_default_profile()[0] == "mycvat"
def test_name_and_unset_conflict(self, store_path, capsys):
_seed(store_path, "mycvat", "https://app.cvat.ai", "t1", default=True)
run_cli(self, "profile", "default", "mycvat", "--unset", expected_code=1)
assert "Cannot combine a profile name with '--unset'." in capsys.readouterr().err
def test_print_no_default_errors(self, store_path, capsys):
run_cli(self, "profile", "default", expected_code=1)
assert "No default profile is set." in capsys.readouterr().err
class TestProfileDelete:
def test_delete_removes_and_clears_default(self, store_path, capsys):
_seed(store_path, "staging", "https://staging.example.com", "t2", default=True)
run_cli(self, "profile", "delete", "staging")
assert "staging" in capsys.readouterr().out
store = AuthStore(path=store_path)
assert store.get_profile("staging") is None
assert store.get_default_profile() is None
def test_delete_leaves_others_and_default_when_not_default(self, store_path):
_seed(store_path, "mycvat", "https://app.cvat.ai", "t1", default=True)
_seed(store_path, "staging", "https://staging.example.com", "t2")
run_cli(self, "profile", "delete", "staging")
store = AuthStore(path=store_path)
assert store.get_profile("staging") is None
assert store.get_default_profile()[0] == "mycvat"
def test_delete_unknown_errors(self, store_path):
run_cli(self, "profile", "delete", "ghost", expected_code=1)
class TestProfileCreate:
def test_create_with_explicit_name_and_token(self, store_path, capsys):
run_cli(
self,
"--server-host",
"https://app.cvat.ai",
"profile",
"create",
"--name",
"mycvat",
"pat-token",
"--set-default",
)
store = AuthStore(path=store_path)
entry = store.get_profile("mycvat")
assert entry.server == "https://app.cvat.ai"
assert entry.token == "pat-token"
assert store.get_default_profile()[0] == "mycvat"
assert "mycvat" in capsys.readouterr().out
def test_create_prompts_for_token_without_echo(self, store_path, monkeypatch):
prompts = []
monkeypatch.setattr(
"getpass.getpass", lambda prompt: prompts.append(prompt) or "prompted-pat"
)
run_cli(
self,
"--server-host",
"https://app.cvat.ai",
"profile",
"create",
"--name",
"p",
)
assert AuthStore(path=store_path).get_profile("p").token == "prompted-pat"
assert prompts == ["Personal Access Token (PAT) for 'https://app.cvat.ai': "]
def test_create_existing_requires_force(self, store_path):
_seed(store_path, "p", "https://app.cvat.ai", "old")
run_cli(
self,
"--server-host",
"https://app.cvat.ai",
"profile",
"create",
"--name",
"p",
"new",
expected_code=1,
)
run_cli(
self,
"--server-host",
"https://app.cvat.ai",
"profile",
"create",
"--name",
"p",
"new",
"--force",
)
assert AuthStore(path=store_path).get_profile("p").token == "new"
def test_create_rejects_server_url_with_port_and_server_port(self, store_path):
run_cli(
self,
"--server-host",
"https://app.cvat.ai:8080",
"--server-port",
"8081",
"profile",
"create",
"--name",
"p",
"pat-token",
expected_code=1,
)
assert AuthStore(path=store_path).get_profile("p") is None
def test_create_appends_server_port_to_default_server(self, store_path):
run_cli(
self,
"--server-port",
"8080",
"profile",
"create",
"--name",
"p",
"pat-token",
)
assert AuthStore(path=store_path).get_profile("p").server == "http://localhost:8080"
def test_create_resolves_name_from_server(self, store_path, monkeypatch):
class _Resp:
name = "server-side-name"
class _AuthApi:
def retrieve_access_tokens_self(self):
return _Resp(), None
class _Configuration:
host = "https://app.cvat.ai"
class _ApiClient:
configuration = _Configuration()
auth_api = _AuthApi()
class _FakeClient:
def __init__(self, *a, **k):
self.api_client = _ApiClient()
def login(self, *a, **k):
pass
def __enter__(self):
return self
def __exit__(self, *a):
return False
monkeypatch.setattr("getpass.getpass", lambda *a, **k: "tok-xyz")
monkeypatch.setattr("cvat_cli._internal.commands_profile.Client", _FakeClient)
run_cli(self, "--server-host", "https://app.cvat.ai", "profile", "create")
assert AuthStore(path=store_path).get_profile("server-side-name").token == "tok-xyz"
def test_create_rejects_empty_token(self, store_path, monkeypatch):
monkeypatch.setattr("getpass.getpass", lambda *a, **k: "")
run_cli(
self,
"--server-host",
"https://app.cvat.ai",
"profile",
"create",
"--name",
"p",
expected_code=1,
)
assert AuthStore(path=store_path).get_profile("p") is None
class TestProfileCreateFromFile:
def test_plain_token_file(self, store_path, tmp_path):
f = tmp_path / "pat.txt"
f.write_text(" raw-token\n")
run_cli(
self,
"--server-host",
"https://app.cvat.ai",
"profile",
"create",
"--name",
"release-bot",
"--file",
str(f),
)
assert AuthStore(path=store_path).get_profile("release-bot").token == "raw-token"
def test_empty_token_file_reports_non_empty_pat_error(self, store_path, tmp_path, capsys):
f = tmp_path / "pat.txt"
f.write_text("")
run_cli(
self,
"profile",
"create",
"--name",
"p",
"--file",
str(f),
expected_code=1,
)
assert "A non-empty PAT is required." in capsys.readouterr().err
assert AuthStore(path=store_path).get_profile("p") is None
def test_json_envelope_zero_args(self, store_path, tmp_path):
f = tmp_path / "cvat-token.json"
f.write_text(
json.dumps(
{
"version": 1,
"server": "https://app.cvat.ai",
"name": "my-laptop",
"token": "envelope-pat",
}
)
)
run_cli(self, "profile", "create", "--file", str(f))
entry = AuthStore(path=store_path).get_profile("my-laptop")
assert entry.server == "https://app.cvat.ai"
assert entry.token == "envelope-pat"
def test_jsonc_envelope_supports_comments_and_trailing_commas(self, store_path, tmp_path):
f = tmp_path / "cvat-token.jsonc"
f.write_text("""{
// The server where this token was created.
"server": "https://app.cvat.ai",
"name": "my-laptop",
"token": "envelope-pat",
}""")
run_cli(self, "profile", "create", "--file", str(f))
entry = AuthStore(path=store_path).get_profile("my-laptop")
assert entry.server == "https://app.cvat.ai"
assert entry.token == "envelope-pat"
def test_explicit_name_overrides_envelope(self, store_path, tmp_path):
f = tmp_path / "t.json"
f.write_text(
json.dumps(
{
"version": 1,
"server": "https://app.cvat.ai",
"name": "env-name",
"token": "p",
}
)
)
run_cli(self, "profile", "create", "--name", "explicit", "--file", str(f))
store = AuthStore(path=store_path)
assert store.get_profile("explicit") is not None
assert store.get_profile("env-name") is None
def test_explicit_server_overrides_envelope(self, store_path, tmp_path):
f = tmp_path / "t.json"
f.write_text(
json.dumps(
{
"version": 1,
"server": "https://envelope.example.com",
"name": "n",
"token": "p",
}
)
)
run_cli(
self,
"--server-host",
"https://explicit.example.com",
"profile",
"create",
"--file",
str(f),
)
entry = AuthStore(path=store_path).get_profile("n")
assert entry.server == "https://explicit.example.com"
def test_token_argument_conflicts_with_file(self, store_path, tmp_path, capsys):
f = tmp_path / "pat.txt"
f.write_text("file-token")
run_cli(
self,
"--server-host",
"https://app.cvat.ai",
"profile",
"create",
"--name",
"p",
"argument-token",
"--file",
str(f),
expected_code=1,
)
assert "Cannot combine a PAT argument with '--file'." in capsys.readouterr().err
assert AuthStore(path=store_path).get_profile("p") is None
def test_rejects_non_file_path(self, store_path, tmp_path, capsys):
run_cli(
self,
"profile",
"create",
"--name",
"p",
"--file",
str(tmp_path),
expected_code=1,
)
assert "path must be a regular file" in capsys.readouterr().err
assert AuthStore(path=store_path).get_profile("p") is None
def test_rejects_missing_file(self, store_path, tmp_path, capsys):
run_cli(
self,
"profile",
"create",
"--name",
"p",
"--file",
str(tmp_path / "missing.txt"),
expected_code=1,
)
assert "path must be a regular file" in capsys.readouterr().err
assert AuthStore(path=store_path).get_profile("p") is None
@pytest.mark.parametrize(
("envelope", "error_message"),
[
({}, "JSON envelope field 'token' must be a string"),
({"token": 1}, "JSON envelope field 'token' must be a string"),
({"token": "p", "server": 1}, "JSON envelope field 'server' must be a string"),
({"token": "p", "name": ["p"]}, "JSON envelope field 'name' must be a string"),
],
)
def test_rejects_invalid_json_envelope(
self, store_path, tmp_path, capsys, envelope, error_message
):
f = tmp_path / "invalid.json"
f.write_text(json.dumps(envelope))
run_cli(
self,
"profile",
"create",
"--name",
"p",
"--file",
str(f),
expected_code=1,
)
assert error_message in capsys.readouterr().err
assert AuthStore(path=store_path).get_profile("p") is None
class TestProfileSelection:
def test_profile_conflicts_with_server_host(self, capsys):
run_cli(
self,
"--profile",
"it",
"--server-host",
"https://example.com",
"task",
"ls",
expected_code=1,
)
assert "--profile is mutually exclusive with --server-host/--server-port/--auth." in (
capsys.readouterr().err
)
class TestProfileSelectionE2E(TestCliBase):
@pytest.fixture(autouse=True)
def _isolate_store(self, tmp_path, monkeypatch):
monkeypatch.setattr(
"cvat_sdk.core.auth.get_auth_store_path",
lambda: tmp_path / "cvat" / "auth.json",
)
def test_profile_supplies_host_and_credential(self, access_tokens):
token = next(t for t in access_tokens)["private_key"]
AuthStore().put_profile(
"it",
ProfileEntry(
server=f"{self.host}:{self.port}",
token=token,
created_date="2026-01-01T00:00:00+00:00",
),
)
# No --server-host, no --auth: the profile supplies both.
run_cli(self, "--profile", "it", "task", "ls")