From a6500586754ad07fded12b36e1c0e06b41341cb1 Mon Sep 17 00:00:00 2001 From: Lee Clement Date: Thu, 23 Jul 2026 14:19:37 -0230 Subject: [PATCH 1/7] train: recipe describe + trainRecipe on the canonical v2 create path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebased #510 onto the merged MMPV surface (#497), deduplicating against its canonical v2 trainings plumbing instead of carrying parallel implementations: - rfapi.get_train_recipe — GET .../v2/trainings/recipe (schema + editable template) for a model type. New; no #497 equivalent. - rfapi.create_training_v2 gains train_recipe (camelCase trainRecipe body key) and business_context; replaces this branch's former parallel rfapi.create_training. rfapi.list_version_trainings/get_version_training are dropped in favor of #497's list_trainings_for_version/get_training. - Version.describe_train_recipe(model_type) — SDK wrapper for the describe endpoint. - Version.create_training gains train_recipe/business_context; replaces the former Version.start_training. Keeps the pre-network guard (train_recipe requires model_type — recipes are minted per model type) and folds a top-level epochs into the recipe's hyperparameters via util.train_recipe.fold_epochs_into_recipe (recipe epochs wins; missing hyperparameters key is created), because the server resolves dense-filled recipe epochs ahead of the body value. Version.list_trainings/get_training are dropped: #497's trainings()/Training.refresh() cover them. - Tests re-homed onto the canonical functions; #497's exact-kwargs create assertion extended with the two new pass-through kwargs. Co-Authored-By: Claude Fable 5 --- roboflow/adapters/rfapi.py | 38 +++++++- roboflow/core/version.py | 90 ++++++++++++++++++- roboflow/util/train_recipe.py | 30 +++++++ tests/test_rfapi.py | 154 +++++++++++++++++++++++++++++++- tests/test_version.py | 133 +++++++++++++++++++++++++++ tests/util/test_train_recipe.py | 30 +++++++ 6 files changed, 472 insertions(+), 3 deletions(-) create mode 100644 roboflow/util/train_recipe.py create mode 100644 tests/util/test_train_recipe.py diff --git a/roboflow/adapters/rfapi.py b/roboflow/adapters/rfapi.py index 8fab78e0..91ea0a4e 100644 --- a/roboflow/adapters/rfapi.py +++ b/roboflow/adapters/rfapi.py @@ -197,6 +197,31 @@ def get_training(api_key: str, workspace_url: str, project_url: str, version: st return response.json() +def get_train_recipe( + api_key: str, + workspace_url: str, + project_url: str, + version: str, + *, + model_type: str, +): + """GET /{ws}/{proj}/{version}/v2/trainings/recipe — training schema for a model type. + + Returns the tunable-hyperparameter schema, the allowed online + augmentation/preprocessing steps, and a ready-to-submit ``template`` + that can be edited and passed to ``create_training_v2`` as ``train_recipe``. + """ + encoded_model_type = quote(model_type, safe="") + url = ( + f"{API_URL}/{workspace_url}/{project_url}/{version}/v2/trainings/recipe" + f"?api_key={api_key}&modelType={encoded_model_type}" + ) + response = requests.get(url) + if not response.ok: + raise RoboflowError(response.text) + return response.json() + + def create_training_v2( api_key: str, workspace_url: str, @@ -207,15 +232,22 @@ def create_training_v2( checkpoint: Optional[str] = None, model_type: Optional[str] = None, epochs: Optional[int] = None, + train_recipe: Optional[Dict] = None, + business_context: Optional[str] = None, ): """Create a training on a version (DNA ``trainings.create``). POST /{ws}/{proj}/{version}/v2/trainings. A version may own many trainings, so repeated/concurrent runs are allowed; the backend rejects a second run on a legacy (SMPV) version. Returns ``{trainingId, status, jobId}``. + + ``train_recipe`` submits a full recipe (camelCase ``trainRecipe`` body + key) — typically the ``template`` from ``get_train_recipe`` with edited + hyperparameters/online augmentation; the server dense-fills omitted + defaults. Only non-None arguments are sent. """ url = f"{API_URL}/{workspace_url}/{project_url}/{version}/v2/trainings?api_key={api_key}" - data: Dict[str, Union[str, int]] = {} + data: Dict[str, Union[str, int, Dict]] = {} if speed is not None: data["speed"] = speed if checkpoint is not None: @@ -224,6 +256,10 @@ def create_training_v2( data["modelType"] = model_type if epochs is not None: data["epochs"] = epochs + if train_recipe is not None: + data["trainRecipe"] = train_recipe + if business_context is not None: + data["business_context"] = business_context response = requests.post(url, json=data) if not response.ok: raise RoboflowError(response.text) diff --git a/roboflow/core/version.py b/roboflow/core/version.py index 07b7418a..ee32f1c5 100644 --- a/roboflow/core/version.py +++ b/roboflow/core/version.py @@ -36,6 +36,7 @@ from roboflow.util.annotations import amend_data_yaml from roboflow.util.general import extract_zip, write_line from roboflow.util.model_processor import package_custom_weights_interactive, validate_model_type_for_project +from roboflow.util.train_recipe import fold_epochs_into_recipe from roboflow.util.versions import get_model_format, get_wrong_dependencies_versions if TYPE_CHECKING: @@ -204,16 +205,101 @@ def models(self): result.extend(training.models) return result - def create_training(self, speed=None, model_type=None, checkpoint=None, epochs=None): + def describe_train_recipe(self, model_type: str) -> dict: + """Fetch the v2 training recipe schema and template for a model type. + + Args: + model_type: The model type to describe (e.g. ``"rfdetr-medium"``). + + Returns: + dict: The API response with the tunable ``schema`` + (hyperparameters, allowed online augmentation/preprocessing + steps, input constraints) and a ready-to-submit ``template`` + that can be edited and passed to :meth:`create_training`. + + Raises: + RoboflowError: If the Roboflow API returns an error. + """ + workspace, project, *_ = self.id.rsplit("/") + return rfapi.get_train_recipe( + api_key=self.__api_key, + workspace_url=workspace, + project_url=project, + version=self.version, + model_type=model_type, + ) + + def create_training( + self, speed=None, model_type=None, checkpoint=None, epochs=None, train_recipe=None, business_context=None + ): """Create a v2 training run and return a Training object. Unlike :meth:`train`, this does not block until completion or return a legacy task-specific model. It exposes the MMPV-aware training id so callers can refresh the run, enumerate produced models, and select the model they want. + + To customize hyperparameters or online augmentation, fetch the recipe + template via :meth:`describe_train_recipe`, edit it, and pass it as + ``train_recipe``; the server dense-fills any defaults the recipe + omits. + + Args: + speed: Training speed preset (e.g. ``"fast"``). + model_type: The model type to train (e.g. ``"rfdetr-medium"``). + checkpoint: Checkpoint to start training from. + epochs: Number of epochs to train. When a ``train_recipe`` is + given, this is folded into the recipe's hyperparameters + unless they already set ``"epochs"``, because the server + resolves the recipe's dense-filled epochs ahead of this + top-level value. + train_recipe: A full recipe to submit — typically the + ``template`` from :meth:`describe_train_recipe` with edited + ``hyperparameters`` / ``online_augmentation``. Requires + ``model_type``: recipes are minted per model type, and + without one the platform would train the project's default + architecture instead. + business_context: Free-form context recorded with the training. + + Raises: + ValueError: If ``train_recipe`` is given without ``model_type``. + RoboflowError: If the Roboflow API returns an error. + + Example: + Launch a small learning-rate sweep and poll for completion:: + + import copy + import time + + template = version.describe_train_recipe("rfdetr-medium")["template"] + trainings = [] + for lr in (1e-4, 3e-4, 1e-3): + recipe = copy.deepcopy(template) + recipe["hyperparameters"] = {"lr": lr} + trainings.append( + version.create_training(model_type="rfdetr-medium", train_recipe=recipe) + ) + pending = list(trainings) + while pending: + for training in list(pending): + if training.refresh().status in ("finished", "failed"): + pending.remove(training) + time.sleep(60) """ from roboflow.core.training import Training + if train_recipe is not None and not model_type: + raise ValueError( + "model_type is required when passing train_recipe: recipes are " + "minted per model type (see describe_train_recipe)." + ) + if train_recipe is not None and epochs is not None: + # Fold epochs into the recipe: the server dense-fills recipe + # hyperparameters (including a default epochs) and resolves them + # ahead of the body's top-level epochs, which would otherwise be + # silently ignored. An epochs set in the recipe wins. + train_recipe = fold_epochs_into_recipe(train_recipe, epochs) + self.__wait_if_generating() if model_type: @@ -231,6 +317,8 @@ def create_training(self, speed=None, model_type=None, checkpoint=None, epochs=N checkpoint=checkpoint if checkpoint else None, model_type=model_type if model_type else None, epochs=epochs, + train_recipe=train_recipe, + business_context=business_context if business_context else None, ) return Training(self.__api_key, workspace, project, self.version, raw) diff --git a/roboflow/util/train_recipe.py b/roboflow/util/train_recipe.py new file mode 100644 index 00000000..010b4dd0 --- /dev/null +++ b/roboflow/util/train_recipe.py @@ -0,0 +1,30 @@ +"""Helpers for v2 ``trainRecipe`` payloads. + +``GET .../v2/trainings/recipe`` returns a ready-to-submit ``template``; +callers edit it and submit it via ``rfapi.create_training_v2``. The server +dense-fills omitted defaults server-side. +""" + +from __future__ import annotations + +import copy +from typing import Any, Dict + + +def fold_epochs_into_recipe(recipe: Dict[str, Any], epochs: int) -> Dict[str, Any]: + """Return a copy of *recipe* with *epochs* folded into its hyperparameters. + + The server dense-fills a submitted recipe's hyperparameters (including + a default ``epochs``) and resolves them ahead of the request body's + top-level ``epochs``, so a top-level value submitted alongside a recipe + would otherwise be silently ignored. + + An ``"epochs"`` already set in the recipe's hyperparameters wins; the + ``hyperparameters`` key is created when a hand-written recipe omits it. + The input recipe is not mutated. + """ + folded = copy.deepcopy(recipe) + hyperparameters = dict(folded.get("hyperparameters") or {}) + hyperparameters.setdefault("epochs", epochs) + folded["hyperparameters"] = hyperparameters + return folded diff --git a/tests/test_rfapi.py b/tests/test_rfapi.py index 1e112563..b57eff9d 100644 --- a/tests/test_rfapi.py +++ b/tests/test_rfapi.py @@ -6,7 +6,14 @@ import responses -from roboflow.adapters.rfapi import upload_image +from roboflow.adapters.rfapi import ( + RoboflowError, + create_training_v2, + get_train_recipe, + get_training, + list_trainings_for_version, + upload_image, +) from roboflow.config import API_URL, DEFAULT_BATCH_NAME @@ -198,5 +205,150 @@ def _reset_responses(self): responses.reset() +class TestV2Trainings(unittest.TestCase): + API_KEY = "test_api_key" + WORKSPACE = "test-workspace" + PROJECT = "test-project" + VERSION = "3" + BASE_URL = f"{API_URL}/{WORKSPACE}/{PROJECT}/{VERSION}/v2/trainings" + + RECIPE_RESPONSE = { + "modelType": "rfdetr-medium", + "family": "rf-detr", + "taskType": "object-detection", + "schema": {"hyperparameters": [{"key": "lr", "type": "float"}]}, + "template": { + "schema_version": 1, + "input": {}, + "online_preprocessing": [], + "online_augmentation": {"splits": ["train"], "steps": []}, + "source_version": {}, + "hyperparameters": {}, + }, + "usage": "...", + } + + def _request_query(self): + return dict(urllib.parse.parse_qsl(urllib.parse.urlsplit(responses.calls[0].request.url).query)) + + def _request_body(self): + return json.loads(responses.calls[0].request.body) + + @responses.activate + def test_get_train_recipe(self): + responses.add(responses.GET, f"{self.BASE_URL}/recipe", json=self.RECIPE_RESPONSE, status=200) + + result = get_train_recipe(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION, model_type="rfdetr-medium") + + self.assertEqual(result, self.RECIPE_RESPONSE) + query = self._request_query() + self.assertEqual(query["api_key"], self.API_KEY) + self.assertEqual(query["modelType"], "rfdetr-medium") + + @responses.activate + def test_get_train_recipe_raises_on_error(self): + responses.add(responses.GET, f"{self.BASE_URL}/recipe", json={"error": "bad model type"}, status=400) + + with self.assertRaises(RoboflowError): + get_train_recipe(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION, model_type="nope") + + @responses.activate + def test_create_training_v2_sends_only_provided_keys_in_camel_case(self): + responses.add( + responses.POST, + self.BASE_URL, + json={"trainingId": "abc123", "status": "queued", "jobId": "job-1"}, + status=200, + ) + + recipe = {"schema_version": 1, "hyperparameters": {"lr": 0.0002}} + result = create_training_v2( + self.API_KEY, + self.WORKSPACE, + self.PROJECT, + self.VERSION, + model_type="rfdetr-medium", + speed="fast", + checkpoint="ckpt", + epochs=10, + train_recipe=recipe, + business_context="sweep run 1", + ) + + self.assertEqual(result["trainingId"], "abc123") + self.assertEqual(self._request_query()["api_key"], self.API_KEY) + body = self._request_body() + self.assertEqual( + body, + { + "modelType": "rfdetr-medium", + "speed": "fast", + "checkpoint": "ckpt", + "epochs": 10, + "trainRecipe": recipe, + "business_context": "sweep run 1", + }, + ) + + @responses.activate + def test_create_training_v2_omits_none_keys(self): + responses.add(responses.POST, self.BASE_URL, json={"trainingId": "abc123"}, status=200) + + create_training_v2(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION) + + self.assertEqual(self._request_body(), {}) + + @responses.activate + def test_create_training_v2_raises_on_error(self): + responses.add(responses.POST, self.BASE_URL, json={"error": "nope"}, status=500) + + with self.assertRaises(RoboflowError): + create_training_v2(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION, model_type="rfdetr-medium") + + @responses.activate + def test_list_trainings_for_version_unwraps_trainings_key(self): + payload = {"trainings": [{"trainingId": "t-1"}, {"trainingId": "t-2"}]} + responses.add(responses.GET, self.BASE_URL, json=payload, status=200) + + result = list_trainings_for_version(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION) + + self.assertEqual(result, payload["trainings"]) + self.assertEqual(self._request_query(), {"api_key": self.API_KEY}) + + @responses.activate + def test_list_trainings_for_version_raises_on_error(self): + responses.add(responses.GET, self.BASE_URL, json={"error": "nope"}, status=404) + + with self.assertRaises(RoboflowError): + list_trainings_for_version(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION) + + @responses.activate + def test_get_training_with_training_id(self): + responses.add(responses.GET, f"{self.BASE_URL}/get", json={"trainingId": "t-1"}, status=200) + + result = get_training(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION, training_id="t-1") + + self.assertEqual(result, {"trainingId": "t-1"}) + query = self._request_query() + self.assertEqual(query["api_key"], self.API_KEY) + self.assertEqual(query["trainingId"], "t-1") + + @responses.activate + def test_get_training_without_training_id(self): + responses.add(responses.GET, f"{self.BASE_URL}/get", json={"trainingId": "latest"}, status=200) + + result = get_training(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION) + + self.assertEqual(result, {"trainingId": "latest"}) + self.assertNotIn("trainingId", self._request_query()) + + @responses.activate + def test_get_training_raises_on_error(self): + responses.add(responses.GET, f"{self.BASE_URL}/get", json={"error": "nope"}, status=404) + + with self.assertRaises(RoboflowError): + get_training(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION, training_id="missing") + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_version.py b/tests/test_version.py index fefc9597..1995c860 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -336,7 +336,140 @@ def test_create_training_returns_v2_training( checkpoint="ckpt", model_type=None, epochs=10, + train_recipe=None, + business_context=None, ) self.assertEqual(training.training_id, "training-1") self.assertEqual(training.status, "running") self.assertEqual(training.model_type, "yolov11") + + +class V2TrainingRecipeTestCase(unittest.TestCase): + """Base fixture for v2 train-recipe tests: an offline Version fixture.""" + + RECIPE_RESPONSE = { + "modelType": "rfdetr-medium", + "family": "rf-detr", + "taskType": "object-detection", + "schema": {"hyperparameters": [{"key": "lr", "type": "float"}]}, + "template": { + "schema_version": 1, + "input": {}, + "online_preprocessing": [], + "online_augmentation": {"splits": ["train"], "steps": []}, + "source_version": {}, + "hyperparameters": {}, + }, + "usage": "...", + } + + def setUp(self): + super().setUp() + self.version = get_version( + project_name="Test Dataset", + id="test-workspace/test-project/2", + version_number="4", + ) + + +class TestDescribeTrainRecipe(V2TrainingRecipeTestCase): + def test_describe_train_recipe_passes_through(self): + with patch.object(rfapi, "get_train_recipe", return_value=self.RECIPE_RESPONSE) as mock_recipe: + result = self.version.describe_train_recipe("rfdetr-medium") + + self.assertEqual(result, self.RECIPE_RESPONSE) + mock_recipe.assert_called_once_with( + api_key="test-api-key", + workspace_url="test-workspace", + project_url="test-project", + version="4", + model_type="rfdetr-medium", + ) + + +class TestCreateTrainingWithRecipe(V2TrainingRecipeTestCase): + """The train_recipe/business_context extension of Version.create_training.""" + + CREATE_RESPONSE = {"trainingId": "t-1", "status": "queued", "jobId": "job-1"} + NOT_GENERATING = {"version": {"generating": False, "progress": 1.0, "images": 10}} + + def _create(self, **kwargs): + with ( + patch.object(rfapi, "get_version", return_value=self.NOT_GENERATING), + patch.object(rfapi, "get_train_recipe", return_value=self.RECIPE_RESPONSE) as mock_recipe, + patch.object(rfapi, "create_training_v2", return_value=self.CREATE_RESPONSE) as mock_create, + patch.object(Version, "export", return_value=True) as mock_export, + ): + result = self.version.create_training(**kwargs) + return result, mock_recipe, mock_create, mock_export + + def test_create_training_requires_model_type_for_train_recipe(self): + # Raised before any network call: neither create nor generation polling runs. + with patch.object(rfapi, "create_training_v2") as mock_create: + with self.assertRaises(ValueError): + self.version.create_training(train_recipe={"schema_version": 1}) + mock_create.assert_not_called() + + def test_recipe_kwargs_pass_through_to_canonical_create(self): + result, mock_recipe, mock_create, mock_export = self._create( + model_type="rfdetr-medium", + epochs=10, + speed="fast", + checkpoint="ckpt", + business_context="baseline", + ) + + self.assertEqual(result.training_id, "t-1") + self.assertEqual(result.status, "queued") + mock_recipe.assert_not_called() + mock_export.assert_called_once_with("coco") + mock_create.assert_called_once_with( + api_key="test-api-key", + workspace_url="test-workspace", + project_url="test-project", + version="4", + speed="fast", + checkpoint="ckpt", + model_type="rfdetr-medium", + epochs=10, + train_recipe=None, + business_context="baseline", + ) + + def test_explicit_recipe_submitted_as_is_without_describe(self): + recipe = {"schema_version": 1, "hyperparameters": {"lr": 0.5}} + _, mock_recipe, mock_create, mock_export = self._create(model_type="rfdetr-medium", train_recipe=recipe) + + mock_recipe.assert_not_called() # no describe fetch on the explicit-recipe path + mock_export.assert_called_once_with("coco") # model_type is required, so export is ensured + self.assertEqual(mock_create.call_args.kwargs["train_recipe"], recipe) + self.assertEqual(mock_create.call_args.kwargs["model_type"], "rfdetr-medium") + + def test_epochs_folded_into_explicit_recipe(self): + recipe = {"schema_version": 1, "hyperparameters": {"lr": 0.5}} + _, mock_recipe, mock_create, _ = self._create(model_type="rfdetr-medium", train_recipe=recipe, epochs=50) + + mock_recipe.assert_not_called() + submitted = mock_create.call_args.kwargs["train_recipe"] + self.assertEqual(submitted["hyperparameters"], {"lr": 0.5, "epochs": 50}) + self.assertEqual(mock_create.call_args.kwargs["epochs"], 50) + # The caller's recipe dict is not mutated by the fold. + self.assertEqual(recipe["hyperparameters"], {"lr": 0.5}) + + def test_explicit_recipe_epochs_wins_over_argument(self): + recipe = {"schema_version": 1, "hyperparameters": {"epochs": 25}} + _, _, mock_create, _ = self._create(model_type="rfdetr-medium", train_recipe=recipe, epochs=50) + + submitted = mock_create.call_args.kwargs["train_recipe"] + self.assertEqual(submitted["hyperparameters"]["epochs"], 25) + + def test_epochs_fold_creates_hyperparameters_in_explicit_recipe(self): + _, _, mock_create, _ = self._create(model_type="rfdetr-medium", train_recipe={"schema_version": 1}, epochs=50) + + submitted = mock_create.call_args.kwargs["train_recipe"] + self.assertEqual(submitted["hyperparameters"], {"epochs": 50}) + + def test_export_skipped_when_format_already_present(self): + self.version.exports = ["coco"] + _, _, _, mock_export = self._create(model_type="rfdetr-medium") + mock_export.assert_not_called() diff --git a/tests/util/test_train_recipe.py b/tests/util/test_train_recipe.py new file mode 100644 index 00000000..1419f463 --- /dev/null +++ b/tests/util/test_train_recipe.py @@ -0,0 +1,30 @@ +import unittest + +from roboflow.util.train_recipe import fold_epochs_into_recipe + + +class TestFoldEpochsIntoRecipe(unittest.TestCase): + def test_epochs_folded_into_hyperparameters(self): + recipe = {"schema_version": 1, "hyperparameters": {"lr": 0.0002}} + folded = fold_epochs_into_recipe(recipe, 50) + self.assertEqual(folded["hyperparameters"], {"lr": 0.0002, "epochs": 50}) + self.assertEqual(folded["schema_version"], 1) + + def test_epochs_does_not_clobber_explicit_hyperparameter(self): + recipe = {"schema_version": 1, "hyperparameters": {"lr": 0.0002, "epochs": 25}} + folded = fold_epochs_into_recipe(recipe, 50) + self.assertEqual(folded["hyperparameters"]["epochs"], 25) + + def test_epochs_fold_creates_missing_hyperparameters_key(self): + # Hand-written recipes may omit the hyperparameters key entirely. + folded = fold_epochs_into_recipe({"schema_version": 1}, 50) + self.assertEqual(folded["hyperparameters"], {"epochs": 50}) + + def test_input_recipe_is_not_mutated(self): + recipe = {"schema_version": 1, "hyperparameters": {"lr": 0.0002}} + fold_epochs_into_recipe(recipe, 50) + self.assertEqual(recipe, {"schema_version": 1, "hyperparameters": {"lr": 0.0002}}) + + +if __name__ == "__main__": + unittest.main() From ed6f4890126d54440fe6415731c59adf581f0e62 Mon Sep 17 00:00:00 2001 From: Lee Clement Date: Thu, 23 Jul 2026 14:19:48 -0230 Subject: [PATCH 2/7] cli: train recipe command + --train-recipe on train start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - roboflow train recipe -p -v -m — print the recipe schema and ready-to-edit template as JSON (both output modes). - --train-recipe on train start (and the bare train callback): inline JSON or a curl-style @file reference. Routes through the canonical rfapi.create_training_v2 (from #497) and prints the new trainingId instead of blocking. Structured errors, all before any network call: unreadable @file, invalid JSON, non-object JSON, and a missing model type (recipes are minted per model type). - --epochs is folded into the recipe's hyperparameters unless the recipe already sets epochs (the server resolves recipe epochs ahead of the body value). Co-Authored-By: Claude Fable 5 --- roboflow/cli/handlers/train.py | 188 +++++++++++++++++++- tests/cli/test_train_handler.py | 300 ++++++++++++++++++++++++++++++++ 2 files changed, 487 insertions(+), 1 deletion(-) diff --git a/roboflow/cli/handlers/train.py b/roboflow/cli/handlers/train.py index f5cbd449..c775e895 100644 --- a/roboflow/cli/handlers/train.py +++ b/roboflow/cli/handlers/train.py @@ -22,6 +22,17 @@ def _train_callback( checkpoint: Annotated[Optional[str], typer.Option(help="Checkpoint to resume training from")] = None, speed: Annotated[Optional[str], typer.Option(help="Training speed preset")] = None, epochs: Annotated[Optional[int], typer.Option(help="Number of training epochs")] = None, + train_recipe: Annotated[ + Optional[str], + typer.Option( + "--train-recipe", + help=( + "Full trainRecipe as inline JSON or @path/to/file.json (see 'roboflow train " + "recipe'); --epochs is folded into its hyperparameters unless the recipe " + "already sets epochs" + ), + ), + ] = None, ) -> None: """Train a model. When invoked without a subcommand, behaves like ``train start``.""" if ctx.invoked_subcommand is not None: @@ -47,6 +58,7 @@ def _train_callback( checkpoint=checkpoint, speed=speed, epochs=epochs, + train_recipe=train_recipe, ) _start(args) @@ -62,8 +74,26 @@ def start_training( checkpoint: Annotated[Optional[str], typer.Option(help="Checkpoint to resume training from")] = None, speed: Annotated[Optional[str], typer.Option(help="Training speed preset")] = None, epochs: Annotated[Optional[int], typer.Option(help="Number of training epochs")] = None, + train_recipe: Annotated[ + Optional[str], + typer.Option( + "--train-recipe", + help=( + "Full trainRecipe as inline JSON or @path/to/file.json (see 'roboflow train " + "recipe'); --epochs is folded into its hyperparameters unless the recipe " + "already sets epochs" + ), + ), + ] = None, ) -> None: - """Start training for a dataset version.""" + """Start training for a dataset version. + + With --train-recipe, the training is created via the v2 trainings API + and the new trainingId is printed. Start from the ``template`` field of + ``roboflow train recipe`` output, edit it (hyperparameters, online + augmentation), and pass it inline or as ``@path/to/file.json``; --epochs is folded into its + hyperparameters unless the recipe already sets epochs. + """ args = ctx_to_args( ctx, project=project, @@ -72,10 +102,31 @@ def start_training( checkpoint=checkpoint, speed=speed, epochs=epochs, + train_recipe=train_recipe, ) _start(args) +@train_app.command("recipe") +def describe_train_recipe( + ctx: typer.Context, + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")], + version_number: Annotated[int, typer.Option("-v", "--version", help="Version number")], + model_type: Annotated[ + str, + typer.Option("-m", "--model-type", "-t", "--type", help="Model type to describe (e.g. rfdetr-medium)"), + ], +) -> None: + """Show the training recipe schema and template for a model type. + + Prints the tunable hyperparameter schema, the allowed online + augmentation/preprocessing steps, and a ready-to-submit ``template`` + that can be edited and passed to ``roboflow train start --train-recipe``. + """ + args = ctx_to_args(ctx, project=project, version_number=version_number, model_type=model_type) + _recipe(args) + + @train_app.command("cancel") def cancel_training( ctx: typer.Context, @@ -174,6 +225,11 @@ def _start(args): # noqa: ANN001 output_error(args, "No API key found.", hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", exit_code=2) return + # Custom recipes go through the v2 trainings API + if getattr(args, "train_recipe", None): + _start_v2(args, api_key, workspace_url, project_slug) + return + # Ensure the version has the required export format before training if args.model_type: _ensure_export(args, api_key, workspace_url, project_slug, str(args.version_number), args.model_type) @@ -210,6 +266,136 @@ def _start(args): # noqa: ANN001 output(args, data, text=f"Training started for {project_slug} version {args.version_number}.") +def _parse_json_flag(args, raw, flag): + """Parse a JSON-object CLI flag value; exits with a clean error on invalid input. + + Accepts inline JSON, or ``@path/to/file.json`` to read the JSON from a + file (curl-style; unambiguous because ``@`` can never start valid JSON). + """ + import json + import os + + from roboflow.cli._output import output_error + + source = "string" + if raw.startswith("@"): + path = os.path.expanduser(raw[1:]) + try: + with open(path, encoding="utf-8") as f: + raw = f.read() + except OSError as exc: + output_error( + args, + f"Cannot read {flag} file {path}: {exc.strerror or exc}", + hint="Pass inline JSON, or @ pointing to a readable JSON file.", + ) + return None # unreachable: output_error sys.exits + source = "file" + + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + output_error(args, f"Invalid JSON in {flag} {source}: {exc}", hint="Pass a valid JSON string.") + return None # unreachable: output_error sys.exits + if not isinstance(parsed, dict): + output_error( + args, + f"{flag} must be a JSON object, got {type(parsed).__name__}", + hint="Pass a JSON object string, e.g. '{\"lr\": 0.0002}'.", + ) + return None # unreachable: output_error sys.exits + return parsed + + +def _start_v2(args, api_key, workspace_url, project_slug): + """Create a training via the v2 trainings API with a custom trainRecipe.""" + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.util.train_recipe import fold_epochs_into_recipe + + version_str = str(args.version_number) + if not args.model_type: + output_error( + args, + "--train-recipe requires a model type.", + hint=( + "Recipes are minted per model type; without -t/--type the platform " + "would train the project's default architecture. Pass the model type " + "the recipe was described for (e.g. -t rfdetr-medium)." + ), + ) + return + train_recipe = _parse_json_flag(args, args.train_recipe, "--train-recipe") + if args.epochs is not None: + # Fold --epochs into the recipe: the server dense-fills recipe + # hyperparameters (including a default epochs) and resolves them + # ahead of the body's top-level value, which would otherwise be + # silently ignored. An epochs set in the recipe wins. + train_recipe = fold_epochs_into_recipe(train_recipe, args.epochs) + + # Ensure the version has the required export format before training + if args.model_type: + _ensure_export(args, api_key, workspace_url, project_slug, version_str, args.model_type) + + try: + result = rfapi.create_training_v2( + api_key, + workspace_url, + project_slug, + version_str, + model_type=args.model_type, + speed=args.speed, + checkpoint=args.checkpoint, + epochs=args.epochs, + train_recipe=train_recipe, + ) + except rfapi.RoboflowError as exc: + output_error(args, str(exc)) + return + + data = { + "status": "training_created", + "project": project_slug, + "version": args.version_number, + **result, + } + training_id = result.get("trainingId") + output( + args, + data, + text=f"Training created for {project_slug} version {args.version_number}. trainingId: {training_id}", + ) + + +def _recipe(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.cli._resolver import resolve_resource + from roboflow.config import load_roboflow_api_key + + try: + workspace_url, project_slug, _version = resolve_resource(args.project, workspace_override=args.workspace) + except ValueError as exc: + output_error(args, str(exc)) + return + + api_key = args.api_key or load_roboflow_api_key(workspace_url) + if not api_key: + output_error(args, "No API key found.", hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", exit_code=2) + return + + try: + result = rfapi.get_train_recipe( + api_key, workspace_url, project_slug, str(args.version_number), model_type=args.model_type + ) + except rfapi.RoboflowError as exc: + output_error(args, str(exc)) + return + + # No text form — the recipe is structured data; print JSON in both modes. + output(args, result) + + def _ensure_export(args, api_key, workspace_url, project_slug, version_str, model_type): """Check if the version has the required export format; trigger and poll if not.""" import sys diff --git a/tests/cli/test_train_handler.py b/tests/cli/test_train_handler.py index 7d826e6c..c605bc96 100644 --- a/tests/cli/test_train_handler.py +++ b/tests/cli/test_train_handler.py @@ -2,6 +2,8 @@ import io import json +import os +import re import sys import types import unittest @@ -13,6 +15,12 @@ runner = CliRunner() +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") + + +def _strip_ansi(text: str) -> str: + return _ANSI_RE.sub("", text) + class TestTrainRegister(unittest.TestCase): """Verify train handler registers expected subcommands.""" @@ -143,6 +151,298 @@ def test_start_json_error_not_double_encoded(self, mock_train: MagicMock) -> Non self.assertEqual(result["error"]["message"], "Unsupported request") +RECIPE_RESPONSE = { + "modelType": "rfdetr-medium", + "family": "rf-detr", + "taskType": "object-detection", + "schema": {"hyperparameters": [{"key": "lr", "type": "float"}]}, + "template": { + "schema_version": 1, + "input": {}, + "online_preprocessing": [], + "online_augmentation": {"splits": ["train"], "steps": []}, + "source_version": {}, + "hyperparameters": {}, + }, + "usage": "...", +} + + +class TestTrainRecipe(unittest.TestCase): + """`train recipe` describe command.""" + + def _make_args(self, **kwargs: object) -> types.SimpleNamespace: + defaults = { + "json": True, + "api_key": "test-key", + "workspace": "test-ws", + "project": "my-project", + "version_number": 3, + "model_type": "rfdetr-medium", + } + defaults.update(kwargs) + return types.SimpleNamespace(**defaults) + + def _capture_stdout(self, fn, args): + buf = io.StringIO() + old = sys.stdout + sys.stdout = buf + try: + fn(args) + finally: + sys.stdout = old + return buf.getvalue() + + def test_recipe_help(self) -> None: + result = runner.invoke(app, ["train", "recipe", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("model", result.output.lower()) + + @patch("roboflow.adapters.rfapi.get_train_recipe") + def test_recipe_prints_response_as_json(self, mock_recipe: MagicMock) -> None: + from roboflow.cli.handlers.train import _recipe + + mock_recipe.return_value = RECIPE_RESPONSE + out = self._capture_stdout(_recipe, self._make_args()) + + mock_recipe.assert_called_once_with("test-key", "test-ws", "my-project", "3", model_type="rfdetr-medium") + self.assertEqual(json.loads(out), RECIPE_RESPONSE) + + @patch("roboflow.adapters.rfapi.get_train_recipe") + def test_recipe_via_cli_runner(self, mock_recipe: MagicMock) -> None: + mock_recipe.return_value = RECIPE_RESPONSE + result = runner.invoke( + app, + [ + "--api-key", + "test-key", + "--workspace", + "test-ws", + "train", + "recipe", + "-p", + "my-project", + "-v", + "3", + "-m", + "rfdetr-medium", + ], + ) + self.assertEqual(result.exit_code, 0, msg=result.output) + self.assertEqual(json.loads(result.output), RECIPE_RESPONSE) + + @patch("roboflow.adapters.rfapi.get_train_recipe") + def test_recipe_api_error(self, mock_recipe: MagicMock) -> None: + from roboflow.adapters.rfapi import RoboflowError + from roboflow.cli.handlers.train import _recipe + + mock_recipe.side_effect = RoboflowError("no recipe for model type") + with self.assertRaises(SystemExit) as ctx: + _recipe(self._make_args()) + self.assertEqual(ctx.exception.code, 1) + + @patch("roboflow.config.load_roboflow_api_key", return_value=None) + def test_recipe_no_api_key(self, _mock_key: MagicMock) -> None: + from roboflow.cli.handlers.train import _recipe + + with self.assertRaises(SystemExit) as ctx: + _recipe(self._make_args(api_key=None)) + self.assertEqual(ctx.exception.code, 2) + + +class TestTrainStartV2(unittest.TestCase): + """`train start` with --train-recipe goes through v2 create_training_v2.""" + + def _make_args(self, **kwargs: object) -> types.SimpleNamespace: + defaults = { + "json": True, + "api_key": "test-key", + "workspace": "test-ws", + "project": "my-project", + "version_number": 3, + "model_type": "rfdetr-medium", + "checkpoint": None, + "speed": None, + "epochs": None, + "train_recipe": None, + "quiet": True, + } + defaults.update(kwargs) + return types.SimpleNamespace(**defaults) + + def _capture_stdout(self, fn, args): + buf = io.StringIO() + old = sys.stdout + sys.stdout = buf + try: + fn(args) + finally: + sys.stdout = old + return buf.getvalue() + + @patch("roboflow.adapters.rfapi.get_version") + @patch("roboflow.adapters.rfapi.create_training_v2") + @patch("roboflow.adapters.rfapi.get_train_recipe") + def test_start_with_train_recipe_submits_as_is( + self, mock_recipe: MagicMock, mock_create: MagicMock, mock_get_version: MagicMock + ) -> None: + from roboflow.adapters.rfapi import RoboflowError + from roboflow.cli.handlers.train import _start + + mock_create.return_value = {"trainingId": "t-2", "status": "queued"} + mock_get_version.side_effect = RoboflowError("offline") + + recipe = {"schema_version": 1, "hyperparameters": {"lr": 0.5}} + args = self._make_args(train_recipe=json.dumps(recipe)) + out = self._capture_stdout(_start, args) + + mock_recipe.assert_not_called() + self.assertEqual(mock_create.call_args.kwargs["train_recipe"], recipe) + self.assertEqual(json.loads(out)["trainingId"], "t-2") + + def test_start_with_invalid_train_recipe_json(self) -> None: + from roboflow.cli.handlers.train import _start + + args = self._make_args(train_recipe="[unterminated") + buf = io.StringIO() + old = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit) as ctx: + _start(args) + finally: + sys.stderr = old + self.assertEqual(ctx.exception.code, 1) + err = json.loads(buf.getvalue()) + self.assertIn("Invalid JSON", err["error"]["message"]) + + @patch("roboflow.adapters.rfapi.create_training_v2") + def test_start_with_non_object_train_recipe_json(self, mock_create: MagicMock) -> None: + from roboflow.cli.handlers.train import _start + + args = self._make_args(train_recipe="[1]") + buf = io.StringIO() + old = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit) as ctx: + _start(args) + finally: + sys.stderr = old + self.assertEqual(ctx.exception.code, 1) + err = json.loads(buf.getvalue()) + self.assertIn("must be a JSON object", err["error"]["message"]) + self.assertIn("list", err["error"]["message"]) + mock_create.assert_not_called() # rejected before any network call + + @patch("roboflow.adapters.rfapi.create_training_v2") + def test_start_train_recipe_requires_model_type(self, mock_create: MagicMock) -> None: + from roboflow.cli.handlers.train import _start + + args = self._make_args(train_recipe=json.dumps({"schema_version": 1}), model_type=None) + buf = io.StringIO() + old = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit) as ctx: + _start(args) + finally: + sys.stderr = old + self.assertEqual(ctx.exception.code, 1) + err = json.loads(buf.getvalue()) + self.assertIn("requires a model type", err["error"]["message"]) + mock_create.assert_not_called() # rejected before any network call + + @patch("roboflow.adapters.rfapi.get_version") + @patch("roboflow.adapters.rfapi.create_training_v2") + def test_start_with_train_recipe_from_file(self, mock_create: MagicMock, mock_get_version: MagicMock) -> None: + import tempfile + + from roboflow.adapters.rfapi import RoboflowError + from roboflow.cli.handlers.train import _start + + mock_create.return_value = {"trainingId": "t-5", "status": "queued"} + mock_get_version.side_effect = RoboflowError("offline") + + recipe = {"schema_version": 1, "hyperparameters": {"lr": 0.0003}} + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "train_recipe.json") + with open(path, "w", encoding="utf-8") as f: + json.dump(recipe, f) + args = self._make_args(train_recipe=f"@{path}") + self._capture_stdout(_start, args) + + self.assertEqual(mock_create.call_args.kwargs["train_recipe"], recipe) + + @patch("roboflow.adapters.rfapi.create_training_v2") + def test_start_with_missing_train_recipe_file(self, mock_create: MagicMock) -> None: + from roboflow.cli.handlers.train import _start + + args = self._make_args(train_recipe="@/nonexistent/train_recipe.json") + buf = io.StringIO() + old = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit) as ctx: + _start(args) + finally: + sys.stderr = old + self.assertEqual(ctx.exception.code, 1) + err = json.loads(buf.getvalue()) + self.assertIn("Cannot read --train-recipe file", err["error"]["message"]) + mock_create.assert_not_called() # rejected before any network call + + @patch("roboflow.adapters.rfapi.create_training_v2") + def test_start_with_invalid_json_in_train_recipe_file(self, mock_create: MagicMock) -> None: + import tempfile + + from roboflow.cli.handlers.train import _start + + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "train_recipe.json") + with open(path, "w", encoding="utf-8") as f: + f.write("{not json") + args = self._make_args(train_recipe=f"@{path}") + buf = io.StringIO() + old = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit) as ctx: + _start(args) + finally: + sys.stderr = old + self.assertEqual(ctx.exception.code, 1) + err = json.loads(buf.getvalue()) + self.assertIn("Invalid JSON in --train-recipe file", err["error"]["message"]) + mock_create.assert_not_called() + + @patch("roboflow.adapters.rfapi.get_version") + @patch("roboflow.adapters.rfapi.create_training_v2") + def test_start_with_train_recipe_and_epochs_folds_epochs( + self, mock_create: MagicMock, mock_get_version: MagicMock + ) -> None: + from roboflow.adapters.rfapi import RoboflowError + from roboflow.cli.handlers.train import _start + + mock_create.return_value = {"trainingId": "t-4", "status": "queued"} + mock_get_version.side_effect = RoboflowError("offline") + + recipe = {"schema_version": 1, "hyperparameters": {"lr": 0.5}} + args = self._make_args(train_recipe=json.dumps(recipe), epochs=50) + self._capture_stdout(_start, args) + + create_kwargs = mock_create.call_args.kwargs + self.assertEqual(create_kwargs["train_recipe"]["hyperparameters"], {"lr": 0.5, "epochs": 50}) + self.assertEqual(create_kwargs["epochs"], 50) + + def test_start_help_shows_train_recipe_flag_only(self) -> None: + result = runner.invoke(app, ["train", "start", "--help"]) + self.assertEqual(result.exit_code, 0) + output = _strip_ansi(result.output) + self.assertIn("--train-recipe", output) + self.assertNotIn("--hyperparameters", output) + + class TestTrainSubcommandsRegister(unittest.TestCase): """train cancel/stop/results subcommands register correctly.""" From b047ebef0497376bb587e78d2ac44fae985d5209 Mon Sep 17 00:00:00 2001 From: Lee Clement Date: Thu, 23 Jul 2026 14:19:57 -0230 Subject: [PATCH 3/7] docs: changelog + CLI-COMMANDS for custom train recipes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CHANGELOG entry reconciled with #497's 1.4.0 MMPV section: #510 now describes only what it adds on top of the canonical surface — the recipe describe endpoint, train_recipe/business_context on Version.create_training, the epochs fold, and the CLI recipe surface. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 18 ++++++++++++++++++ CLI-COMMANDS.md | 21 +++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index aeef309c..576aa468 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,24 @@ models (e.g. a NAS sweep). New object types expose this: `upload_model` detects them and rebuilds a deploy-ready bundle via rf-detr's `export_for_roboflow` (requires `rfdetr>=1.8.0`) ([#488](https://github.com/roboflow/roboflow-python/pull/488)) +- Custom train recipes on v2 trainings + ([#510](https://github.com/roboflow/roboflow-python/pull/510)): + - `Version.describe_train_recipe(model_type)` — fetch the tunable + hyperparameter schema, allowed online augmentation/preprocessing steps, + and a ready-to-edit recipe `template` for a model type. + - `train_recipe` and `business_context` on `Version.create_training(...)` — + pass an edited `describe_train_recipe` template for custom + hyperparameters/online augmentation (the server dense-fills omitted + defaults). A top-level `epochs` is folded into the recipe's + hyperparameters (the server resolves recipe epochs ahead of the body + value). `train_recipe` requires `model_type` — recipes are minted per + model type, and without one the platform would train the project's + default architecture. + - `roboflow train recipe -p -v -m ` — print the + recipe schema and template as JSON. + - `roboflow train start --train-recipe ''` — create the training + through the v2 API and print the new `trainingId`. Accepts inline JSON + or a curl-style file reference (`--train-recipe @train_recipe.json`). ### Changed diff --git a/CLI-COMMANDS.md b/CLI-COMMANDS.md index 18c9b0a8..4593b802 100644 --- a/CLI-COMMANDS.md +++ b/CLI-COMMANDS.md @@ -72,6 +72,27 @@ roboflow train results my-project/3 NAS sweeps require the version's validation split to have at least 15 images; the server returns `code: "insufficient_validation_images_for_nas"` otherwise. +### Train recipes — custom hyperparameters & augmentation (v2) + +```bash +# Inspect a model type's tunable hyperparameter schema, allowed online +# augmentation/preprocessing steps, and a ready-to-edit recipe template: +roboflow train recipe -p my-project -v 3 -m rfdetr-medium + +# Start a training from an edited recipe: take the `template` field, tweak +# it (hyperparameters, online augmentation), and submit it. The server +# dense-fills any defaults the recipe leaves out: +roboflow --json train recipe -p my-project -v 3 -m rfdetr-medium | jq .template > recipe.json +# ... edit recipe.json (e.g. set .hyperparameters.lr) ... +roboflow train start -p my-project -v 3 -t rfdetr-medium --train-recipe @recipe.json +``` + +--train-recipe accepts inline JSON or a curl-style @file reference; it creates +the training through the v2 trainings API and prints +the new `trainingId` instead of blocking — handy for launching sweeps and +polling status separately. --epochs is folded into the recipe's +hyperparameters unless the recipe already sets epochs. + ### NAS models — list, star, deploy ```bash From ead9d62f691672e4111b1275be11b425f4ce4102 Mon Sep 17 00:00:00 2001 From: Lee Clement Date: Thu, 23 Jul 2026 14:33:17 -0230 Subject: [PATCH 4/7] cli: treat an explicitly empty --train-recipe as an error, not absence An empty supplied value (classic unset shell variable expansion) passed the truthiness check and fell through to the legacy training endpoint, silently starting a different, billable training. Presence is now 'is not None', so the empty string reaches the JSON validator and fails with a structured error before any network call. Addresses external review feedback on PR #510. Co-Authored-By: Claude Fable 5 --- roboflow/cli/handlers/train.py | 7 +++++-- tests/cli/test_train_handler.py | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/roboflow/cli/handlers/train.py b/roboflow/cli/handlers/train.py index c775e895..8dd7c229 100644 --- a/roboflow/cli/handlers/train.py +++ b/roboflow/cli/handlers/train.py @@ -225,8 +225,11 @@ def _start(args): # noqa: ANN001 output_error(args, "No API key found.", hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", exit_code=2) return - # Custom recipes go through the v2 trainings API - if getattr(args, "train_recipe", None): + # Custom recipes go through the v2 trainings API. Presence, not + # truthiness: an explicitly supplied empty value (e.g. an unset shell + # variable) must fail JSON validation, not fall through and start a + # legacy training. + if getattr(args, "train_recipe", None) is not None: _start_v2(args, api_key, workspace_url, project_slug) return diff --git a/tests/cli/test_train_handler.py b/tests/cli/test_train_handler.py index c605bc96..32503de0 100644 --- a/tests/cli/test_train_handler.py +++ b/tests/cli/test_train_handler.py @@ -335,6 +335,30 @@ def test_start_with_non_object_train_recipe_json(self, mock_create: MagicMock) - self.assertIn("list", err["error"]["message"]) mock_create.assert_not_called() # rejected before any network call + @patch("roboflow.adapters.rfapi.start_version_training") + @patch("roboflow.adapters.rfapi.create_training_v2") + def test_start_with_empty_train_recipe_errors_without_training( + self, mock_create: MagicMock, mock_legacy: MagicMock + ) -> None: + """--train-recipe "" (e.g. an unset shell variable) must error, not + fall through to the legacy endpoint and start a different training.""" + from roboflow.cli.handlers.train import _start + + args = self._make_args(train_recipe="") + buf = io.StringIO() + old = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit) as ctx: + _start(args) + finally: + sys.stderr = old + self.assertEqual(ctx.exception.code, 1) + err = json.loads(buf.getvalue()) + self.assertIn("Invalid JSON", err["error"]["message"]) + mock_create.assert_not_called() + mock_legacy.assert_not_called() # the real hazard: no legacy fallback + @patch("roboflow.adapters.rfapi.create_training_v2") def test_start_train_recipe_requires_model_type(self, mock_create: MagicMock) -> None: from roboflow.cli.handlers.train import _start From d457708f67722bf549b0bf960355ade6f10a93e5 Mon Sep 17 00:00:00 2001 From: Lee Clement Date: Thu, 23 Jul 2026 14:43:41 -0230 Subject: [PATCH 5/7] sdk: drop business_context from the recipe PR Unrelated to recipes: it is a pre-existing platform field the MCP tool already exposes, and it slipped into this PR as payload parity rather than a considered scope decision. If SDK parity is wanted it can ride its own follow-up. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- roboflow/adapters/rfapi.py | 3 --- roboflow/core/version.py | 6 +----- tests/test_rfapi.py | 2 -- tests/test_version.py | 5 +---- 5 files changed, 3 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 576aa468..dcfe12e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,7 +43,7 @@ models (e.g. a NAS sweep). New object types expose this: - `Version.describe_train_recipe(model_type)` — fetch the tunable hyperparameter schema, allowed online augmentation/preprocessing steps, and a ready-to-edit recipe `template` for a model type. - - `train_recipe` and `business_context` on `Version.create_training(...)` — + - `train_recipe` on `Version.create_training(...)` — pass an edited `describe_train_recipe` template for custom hyperparameters/online augmentation (the server dense-fills omitted defaults). A top-level `epochs` is folded into the recipe's diff --git a/roboflow/adapters/rfapi.py b/roboflow/adapters/rfapi.py index 91ea0a4e..bdf8067e 100644 --- a/roboflow/adapters/rfapi.py +++ b/roboflow/adapters/rfapi.py @@ -233,7 +233,6 @@ def create_training_v2( model_type: Optional[str] = None, epochs: Optional[int] = None, train_recipe: Optional[Dict] = None, - business_context: Optional[str] = None, ): """Create a training on a version (DNA ``trainings.create``). @@ -258,8 +257,6 @@ def create_training_v2( data["epochs"] = epochs if train_recipe is not None: data["trainRecipe"] = train_recipe - if business_context is not None: - data["business_context"] = business_context response = requests.post(url, json=data) if not response.ok: raise RoboflowError(response.text) diff --git a/roboflow/core/version.py b/roboflow/core/version.py index ee32f1c5..4f5b940c 100644 --- a/roboflow/core/version.py +++ b/roboflow/core/version.py @@ -229,9 +229,7 @@ def describe_train_recipe(self, model_type: str) -> dict: model_type=model_type, ) - def create_training( - self, speed=None, model_type=None, checkpoint=None, epochs=None, train_recipe=None, business_context=None - ): + def create_training(self, speed=None, model_type=None, checkpoint=None, epochs=None, train_recipe=None): """Create a v2 training run and return a Training object. Unlike :meth:`train`, this does not block until completion or return a @@ -259,7 +257,6 @@ def create_training( ``model_type``: recipes are minted per model type, and without one the platform would train the project's default architecture instead. - business_context: Free-form context recorded with the training. Raises: ValueError: If ``train_recipe`` is given without ``model_type``. @@ -318,7 +315,6 @@ def create_training( model_type=model_type if model_type else None, epochs=epochs, train_recipe=train_recipe, - business_context=business_context if business_context else None, ) return Training(self.__api_key, workspace, project, self.version, raw) diff --git a/tests/test_rfapi.py b/tests/test_rfapi.py index b57eff9d..30acf5cd 100644 --- a/tests/test_rfapi.py +++ b/tests/test_rfapi.py @@ -272,7 +272,6 @@ def test_create_training_v2_sends_only_provided_keys_in_camel_case(self): checkpoint="ckpt", epochs=10, train_recipe=recipe, - business_context="sweep run 1", ) self.assertEqual(result["trainingId"], "abc123") @@ -286,7 +285,6 @@ def test_create_training_v2_sends_only_provided_keys_in_camel_case(self): "checkpoint": "ckpt", "epochs": 10, "trainRecipe": recipe, - "business_context": "sweep run 1", }, ) diff --git a/tests/test_version.py b/tests/test_version.py index 1995c860..4cb66e91 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -337,7 +337,6 @@ def test_create_training_returns_v2_training( model_type=None, epochs=10, train_recipe=None, - business_context=None, ) self.assertEqual(training.training_id, "training-1") self.assertEqual(training.status, "running") @@ -388,7 +387,7 @@ def test_describe_train_recipe_passes_through(self): class TestCreateTrainingWithRecipe(V2TrainingRecipeTestCase): - """The train_recipe/business_context extension of Version.create_training.""" + """The train_recipe extension of Version.create_training.""" CREATE_RESPONSE = {"trainingId": "t-1", "status": "queued", "jobId": "job-1"} NOT_GENERATING = {"version": {"generating": False, "progress": 1.0, "images": 10}} @@ -416,7 +415,6 @@ def test_recipe_kwargs_pass_through_to_canonical_create(self): epochs=10, speed="fast", checkpoint="ckpt", - business_context="baseline", ) self.assertEqual(result.training_id, "t-1") @@ -433,7 +431,6 @@ def test_recipe_kwargs_pass_through_to_canonical_create(self): model_type="rfdetr-medium", epochs=10, train_recipe=None, - business_context="baseline", ) def test_explicit_recipe_submitted_as_is_without_describe(self): From 927fb5722c199642f09561ca13259d8f49ad9ea6 Mon Sep 17 00:00:00 2001 From: Lee Clement Date: Thu, 23 Jul 2026 14:50:52 -0230 Subject: [PATCH 6/7] docs: move the #510 changelog entry to a new Unreleased section 1.4.0 already shipped; this PR's additions ride the next release. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 41 +++++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcfe12e4..3b72391b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ All notable changes to this project will be documented in this file. +## Unreleased + +### Added + +- Custom train recipes on v2 trainings + ([#510](https://github.com/roboflow/roboflow-python/pull/510)): + - `Version.describe_train_recipe(model_type)` — fetch the tunable + hyperparameter schema, allowed online augmentation/preprocessing steps, + and a ready-to-edit recipe `template` for a model type. + - `train_recipe` on `Version.create_training(...)` — + pass an edited `describe_train_recipe` template for custom + hyperparameters/online augmentation (the server dense-fills omitted + defaults). A top-level `epochs` is folded into the recipe's + hyperparameters (the server resolves recipe epochs ahead of the body + value). `train_recipe` requires `model_type` — recipes are minted per + model type, and without one the platform would train the project's + default architecture. + - `roboflow train recipe -p -v -m ` — print the + recipe schema and template as JSON. + - `roboflow train start --train-recipe ''` — create the training + through the v2 API and print the new `trainingId`. Accepts inline JSON + or a curl-style file reference (`--train-recipe @train_recipe.json`). + ## 1.4.0 ### Added — Support for multiple models per version @@ -38,24 +61,6 @@ models (e.g. a NAS sweep). New object types expose this: `upload_model` detects them and rebuilds a deploy-ready bundle via rf-detr's `export_for_roboflow` (requires `rfdetr>=1.8.0`) ([#488](https://github.com/roboflow/roboflow-python/pull/488)) -- Custom train recipes on v2 trainings - ([#510](https://github.com/roboflow/roboflow-python/pull/510)): - - `Version.describe_train_recipe(model_type)` — fetch the tunable - hyperparameter schema, allowed online augmentation/preprocessing steps, - and a ready-to-edit recipe `template` for a model type. - - `train_recipe` on `Version.create_training(...)` — - pass an edited `describe_train_recipe` template for custom - hyperparameters/online augmentation (the server dense-fills omitted - defaults). A top-level `epochs` is folded into the recipe's - hyperparameters (the server resolves recipe epochs ahead of the body - value). `train_recipe` requires `model_type` — recipes are minted per - model type, and without one the platform would train the project's - default architecture. - - `roboflow train recipe -p -v -m ` — print the - recipe schema and template as JSON. - - `roboflow train start --train-recipe ''` — create the training - through the v2 API and print the new `trainingId`. Accepts inline JSON - or a curl-style file reference (`--train-recipe @train_recipe.json`). ### Changed From a9b040ba45512e4c2891a859abb354f4243d7991 Mon Sep 17 00:00:00 2001 From: Lee Clement Date: Thu, 23 Jul 2026 16:38:25 -0230 Subject: [PATCH 7/7] changelog 1.4.1 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b72391b..bb35f49c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to this project will be documented in this file. -## Unreleased +## 1.4.1 ### Added