Skip to content

Commit fc554e4

Browse files
authored
[ar-api] Return Action Recognition training IDs from CLI (VID-34) (#537)
* [ar-api] Return Action Recognition training IDs from CLI (VID-34) * Keep Action Recognition CLI help concise (VID-34)
1 parent 2c83534 commit fc554e4

2 files changed

Lines changed: 109 additions & 15 deletions

File tree

‎roboflow/cli/handlers/train.py‎

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,8 @@ def start_training(
8888
) -> None:
8989
"""Start training for a dataset version.
9090
91-
With --train-recipe, the training is created via the v2 trainings API
92-
and the new trainingId is printed. Start from the ``template`` field of
91+
With --train-recipe, the training uses v2 and prints its trainingId.
92+
Action Recognition also uses v2 without a recipe. Start from the ``template`` field of
9393
``roboflow train recipe`` output, edit it (hyperparameters, online
9494
augmentation), and pass it inline or as ``@path/to/file.json``; --epochs is folded into its
9595
hyperparameters unless the recipe already sets epochs.
@@ -297,11 +297,10 @@ def _start(args): # noqa: ANN001
297297
output_error(args, "No API key found.", hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", exit_code=2)
298298
return
299299

300-
# Custom recipes go through the v2 trainings API. Presence, not
301-
# truthiness: an explicitly supplied empty value (e.g. an unset shell
302-
# variable) must fail JSON validation, not fall through and start a
303-
# legacy training.
304-
if getattr(args, "train_recipe", None) is not None:
300+
# Exact Cosmos 3 Edge and custom recipes use v2 so callers receive the
301+
# trainingId. Presence, not truthiness: an explicitly supplied empty
302+
# recipe must fail validation rather than start another training.
303+
if args.model_type == "cosmos3-edge" or getattr(args, "train_recipe", None) is not None:
305304
_start_v2(args, api_key, workspace_url, project_slug)
306305
return
307306

@@ -383,7 +382,7 @@ def _parse_json_flag(args, raw, flag):
383382

384383

385384
def _start_v2(args, api_key, workspace_url, project_slug):
386-
"""Create a training via the v2 trainings API with a custom trainRecipe."""
385+
"""Create a v2 training, with a trainRecipe only when supplied."""
387386
from roboflow.adapters import rfapi
388387
from roboflow.cli._output import output, output_error
389388
from roboflow.util.train_recipe import fold_epochs_into_recipe
@@ -400,13 +399,13 @@ def _start_v2(args, api_key, workspace_url, project_slug):
400399
),
401400
)
402401
return
403-
train_recipe = _parse_json_flag(args, args.train_recipe, "--train-recipe")
404-
if args.epochs is not None:
405-
# Fold --epochs into the recipe: the server dense-fills recipe
406-
# hyperparameters (including a default epochs) and resolves them
407-
# ahead of the body's top-level value, which would otherwise be
408-
# silently ignored. An epochs set in the recipe wins.
409-
train_recipe = fold_epochs_into_recipe(train_recipe, args.epochs)
402+
train_recipe = args.train_recipe
403+
if train_recipe is not None:
404+
train_recipe = _parse_json_flag(args, train_recipe, "--train-recipe")
405+
if args.epochs is not None:
406+
# The server resolves recipe epochs ahead of the top-level value.
407+
# An explicit recipe value wins over --epochs.
408+
train_recipe = fold_epochs_into_recipe(train_recipe, args.epochs)
410409

411410
# Ensure the version has the required export format before training
412411
if args.model_type:

‎tests/cli/test_train_handler.py‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@
88
import types
99
import unittest
1010
from unittest.mock import MagicMock, patch
11+
from urllib.parse import parse_qs, urlsplit
1112

13+
import responses
1214
from typer.testing import CliRunner
1315

1416
from roboflow.cli import app
17+
from roboflow.config import API_URL
1518

1619
runner = CliRunner()
1720

@@ -464,9 +467,101 @@ def test_start_help_shows_train_recipe_flag_only(self) -> None:
464467
self.assertEqual(result.exit_code, 0)
465468
output = _strip_ansi(result.output)
466469
self.assertIn("--train-recipe", output)
470+
self.assertIn("Action Recognition", output)
471+
self.assertIn("trainingId", output)
467472
self.assertNotIn("--hyperparameters", output)
468473

469474

475+
class TestActionRecognitionTrainCLI(unittest.TestCase):
476+
"""Exercise the public train command through its real HTTP adapters."""
477+
478+
BASE = f"{API_URL}/audit-ws/audit-project/1"
479+
480+
def _invoke(self, model_type, *, json_output=True, recipe=None):
481+
args = ["--api-key", "fixture-key", "--workspace", "audit-ws", "--quiet"]
482+
if json_output:
483+
args.append("--json")
484+
args.extend(["train", "start", "-p", "audit-project", "-v", "1", "-t", model_type, "--epochs", "5"])
485+
if recipe is not None:
486+
args.extend(["--train-recipe", recipe])
487+
return runner.invoke(app, args)
488+
489+
def test_recipe_free_cosmos_exports_video_coco_and_returns_id_in_json_and_text(self):
490+
for json_output in (True, False):
491+
with self.subTest(json_output=json_output), responses.RequestsMock() as mocked, patch("time.sleep"):
492+
mocked.add(responses.GET, self.BASE, json={"version": {"generating": False, "exports": []}})
493+
mocked.add(responses.GET, f"{self.BASE}/video-coco", json={"export": {"link": "https://fixture.test"}})
494+
mocked.add(responses.GET, self.BASE, json={"version": {"generating": False, "exports": ["video-coco"]}})
495+
mocked.add(
496+
responses.POST,
497+
f"{self.BASE}/v2/trainings",
498+
json={"trainingId": "audit-t1", "status": "queued", "jobId": "audit-j1"},
499+
)
500+
501+
result = self._invoke("cosmos3-edge", json_output=json_output)
502+
503+
self.assertEqual(result.exit_code, 0, result.output)
504+
self.assertIn("audit-t1", result.stdout)
505+
if json_output:
506+
self.assertEqual(json.loads(result.stdout)["trainingId"], "audit-t1")
507+
paths = [urlsplit(call.request.url).path for call in mocked.calls]
508+
self.assertEqual(
509+
paths,
510+
[
511+
"/audit-ws/audit-project/1",
512+
"/audit-ws/audit-project/1/video-coco",
513+
"/audit-ws/audit-project/1",
514+
"/audit-ws/audit-project/1/v2/trainings",
515+
],
516+
)
517+
create = mocked.calls[-1].request
518+
self.assertEqual(parse_qs(urlsplit(create.url).query)["api_key"], ["fixture-key"])
519+
self.assertEqual(json.loads(create.body), {"modelType": "cosmos3-edge", "epochs": 5})
520+
521+
def test_recipe_free_other_models_keep_legacy_route(self):
522+
for model_type in ("rfdetr-medium", "cosmos3-edge-vlm"):
523+
with self.subTest(model_type=model_type), responses.RequestsMock() as mocked:
524+
mocked.add(
525+
responses.GET,
526+
self.BASE,
527+
json={"version": {"generating": False, "exports": ["coco", "yolov5pytorch"]}},
528+
)
529+
mocked.add(responses.POST, f"{self.BASE}/train", status=204)
530+
531+
result = self._invoke(model_type)
532+
533+
self.assertEqual(result.exit_code, 0, result.output)
534+
self.assertEqual(json.loads(result.stdout)["status"], "training_started")
535+
self.assertEqual(
536+
[urlsplit(call.request.url).path for call in mocked.calls][-1], "/audit-ws/audit-project/1/train"
537+
)
538+
self.assertEqual(json.loads(mocked.calls[-1].request.body)["modelType"], model_type)
539+
540+
def test_explicit_recipe_still_folds_epochs_and_uses_v2(self):
541+
with responses.RequestsMock() as mocked:
542+
mocked.add(responses.GET, self.BASE, json={"version": {"generating": False, "exports": ["video-coco"]}})
543+
mocked.add(responses.POST, f"{self.BASE}/v2/trainings", json={"trainingId": "audit-t2", "status": "queued"})
544+
545+
result = self._invoke("cosmos3-edge", recipe='{"schema_version":1,"hyperparameters":{"lr":0.1}}')
546+
547+
self.assertEqual(result.exit_code, 0, result.output)
548+
self.assertEqual(json.loads(result.stdout)["trainingId"], "audit-t2")
549+
self.assertEqual(
550+
json.loads(mocked.calls[-1].request.body)["trainRecipe"]["hyperparameters"],
551+
{"lr": 0.1, "epochs": 5},
552+
)
553+
self.assertEqual(len(mocked.calls), 2)
554+
555+
def test_malformed_explicit_recipe_does_not_start_training(self):
556+
for recipe in ("", "{invalid"):
557+
with self.subTest(recipe=recipe), responses.RequestsMock() as mocked:
558+
result = self._invoke("cosmos3-edge", recipe=recipe)
559+
560+
self.assertEqual(result.exit_code, 1)
561+
self.assertIn("Invalid JSON", result.output)
562+
self.assertEqual(len(mocked.calls), 0)
563+
564+
470565
class TestTrainSubcommandsRegister(unittest.TestCase):
471566
"""train cancel/stop/results subcommands register correctly."""
472567

0 commit comments

Comments
 (0)