Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ The CLI is built on [typer](https://typer.tiangolo.com/) (which uses Click under

**Package structure:**
- `__init__.py` — Root `typer.Typer()` app with global `@app.callback()` for `--json`, `--workspace`, `--api-key`, `--quiet`. Explicitly registers all handler apps via `app.add_typer()`.
- `_output.py` — `output(args, data, text)` for JSON/text output, `output_error(args, msg, hint, exit_code)` for structured errors, `suppress_sdk_output()` to silence SDK noise, `stub()` for unimplemented commands
- `_output.py` — `output(args, data, text)` for JSON/text output, `output_error(args, msg, hint, exit_code)` for structured errors, `suppress_sdk_output()` to silence SDK noise, `sdk_output_to_stderr(args)` to move SDK prints to stderr under `--json`, `stub()` for unimplemented commands
- `_compat.py` — `ctx_to_args(ctx, **kwargs)` bridge that converts `typer.Context` to the `SimpleNamespace` that output helpers expect
- `_table.py` — `format_table(rows, columns)` for columnar list output
- `_resolver.py` — `resolve_resource(shorthand)` for parsing `project`, `ws/project`, `ws/project/3`
Expand All @@ -102,7 +102,7 @@ The CLI is built on [typer](https://typer.tiangolo.com/) (which uses Click under
3. Add commands with `@mycommand_app.command("verb")` decorators
4. Each command takes `ctx: typer.Context` + typed params, calls `ctx_to_args(ctx, **params)` to create args namespace
5. Use `output()` for all output, `output_error()` for all errors
6. Wrap SDK calls in `with suppress_sdk_output():` to prevent "loading..." noise
6. Wrap SDK calls in `with suppress_sdk_output():` to prevent "loading..." noise. If the SDK's prints are progress or failure signals worth keeping (per-image `[ERR]`, export progress), use `with sdk_output_to_stderr(args):` instead, so `--json` stdout stays one JSON document
7. Register in `roboflow/cli/__init__.py`: `app.add_typer(mycommand_app, name="mycommand")`
8. Add tests using `typer.testing.CliRunner` in `tests/cli/test_mycommand_handler.py`

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ app.add_typer(mycommand_app, name="mycommand")
- [ ] Supports `--json` via `output()` helper
- [ ] No interactive prompts when all required flags are provided
- [ ] Errors use `output_error(args, message, hint=..., exit_code=N)`
- [ ] SDK calls wrapped in `with suppress_sdk_output():`
- [ ] SDK calls wrapped in `with suppress_sdk_output():` (or `sdk_output_to_stderr(args)` when their prints should stay visible)
- [ ] Exit codes: 0=success, 1=error, 2=auth, 3=not found

**Documentation policy:** `CLI-COMMANDS.md` in this repo is a quickstart only. The comprehensive command reference lives in [`roboflow-dev-reference`](https://github.com/roboflow/roboflow-dev-reference) and is published to docs.roboflow.com/developer/command-line-interface. When adding a new command, update both: add a quick example to `CLI-COMMANDS.md` and the full reference to the dev-reference CLI page.
Expand Down
15 changes: 15 additions & 0 deletions roboflow/cli/_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,3 +281,18 @@ def suppress_sdk_output(args: Any = None) -> Iterator[None]:
"""
with contextlib.redirect_stdout(io.StringIO()):
yield


@contextlib.contextmanager
def sdk_output_to_stderr(args: Any) -> Iterator[None]:
"""Send SDK prints to stderr in ``--json`` mode, so stdout stays one JSON document.

Use around SDK calls whose prints are useful progress or failure signals
(e.g. per-image ``[ERR]`` lines) that ``suppress_sdk_output()`` would hide.
Without ``--json`` it does nothing.
"""
if getattr(args, "json", False):
with contextlib.redirect_stdout(sys.stderr):
yield
else:
yield
29 changes: 15 additions & 14 deletions roboflow/cli/handlers/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ def _handle_upload_directory(args, api_key: str, path: str) -> None: # noqa: AN
import os

import roboflow
from roboflow.cli._output import output, output_error, suppress_sdk_output
from roboflow.cli._output import output, output_error, sdk_output_to_stderr, suppress_sdk_output

# Always suppress SDK "loading..." noise during workspace init
with suppress_sdk_output():
Expand All @@ -376,19 +376,20 @@ def _handle_upload_directory(args, api_key: str, path: str) -> None: # noqa: AN
wait = not getattr(args, "no_wait", False)

try:
result = workspace.upload_dataset(
dataset_path=path,
project_name=args.project,
num_workers=args.concurrency,
batch_name=getattr(args, "batch", None),
num_retries=retries,
is_prediction=getattr(args, "is_prediction", False),
use_zip_upload=getattr(args, "zip_upload", False),
annotation_overwrite=getattr(args, "annotation_overwrite", None),
split=getattr(args, "split", None),
tags=tags,
wait=wait,
)
with sdk_output_to_stderr(args):
result = workspace.upload_dataset(
dataset_path=path,
project_name=args.project,
num_workers=args.concurrency,
batch_name=getattr(args, "batch", None),
num_retries=retries,
is_prediction=getattr(args, "is_prediction", False),
use_zip_upload=getattr(args, "zip_upload", False),
annotation_overwrite=getattr(args, "annotation_overwrite", None),
split=getattr(args, "split", None),
tags=tags,
wait=wait,
)
except Exception as exc:
output_error(args, str(exc))
return
Expand Down
30 changes: 17 additions & 13 deletions roboflow/cli/handlers/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,11 +311,12 @@ def _get_model(args): # noqa: ANN001

def _upload_model(args): # noqa: ANN001
import roboflow
from roboflow.cli._output import output, output_error
from roboflow.cli._output import output, output_error, sdk_output_to_stderr, suppress_sdk_output

api_key = args.api_key or None
rf = roboflow.Roboflow(api_key=api_key)
workspace = rf.workspace(args.workspace)
with suppress_sdk_output():
rf = roboflow.Roboflow(api_key=api_key)
workspace = rf.workspace(args.workspace)

if args.version_number is not None:
# Deploy to a specific version
Expand All @@ -325,9 +326,11 @@ def _upload_model(args): # noqa: ANN001
return

try:
project = workspace.project(project_id)
version = project.version(args.version_number)
version.deploy(str(args.model_type), str(args.model_path), str(args.filename))
with suppress_sdk_output():
project = workspace.project(project_id)
version = project.version(args.version_number)
with sdk_output_to_stderr(args):
version.deploy(str(args.model_type), str(args.model_path), str(args.filename))
except Exception as exc:
output_error(args, str(exc))
return
Expand All @@ -338,13 +341,14 @@ def _upload_model(args): # noqa: ANN001
return

try:
workspace.deploy_model(
model_type=str(args.model_type),
model_path=str(args.model_path),
project_ids=args.project,
model_name=str(args.model_name) if args.model_name else "",
filename=str(args.filename),
)
with sdk_output_to_stderr(args):
workspace.deploy_model(
model_type=str(args.model_type),
model_path=str(args.model_path),
project_ids=args.project,
model_name=str(args.model_name) if args.model_name else "",
filename=str(args.filename),
)
except Exception as exc:
output_error(args, str(exc))
return
Expand Down
4 changes: 2 additions & 2 deletions roboflow/cli/handlers/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,9 +201,9 @@ def _get_project(args): # noqa: ANN001
if key in project:
val = project[key]
if key in epoch_keys and isinstance(val, (int, float)):
import datetime
import datetime as dt

val = datetime.datetime.fromtimestamp(val).strftime("%Y-%m-%d %H:%M:%S")
val = dt.datetime.fromtimestamp(val).strftime("%Y-%m-%d %H:%M:%S")
elif isinstance(val, dict):
val = ", ".join(f"{k}: {v}" for k, v in val.items())
lines.append(f" {label:12s} {val}")
Expand Down
21 changes: 11 additions & 10 deletions roboflow/cli/handlers/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,18 +149,19 @@ def _describe_hit(hit: dict) -> str:


def _do_export(args: Any, workspace: Any) -> None:
from roboflow.cli._output import output, output_error
from roboflow.cli._output import output, output_error, sdk_output_to_stderr

try:
result_path = workspace.search_export(
query=args.query,
format=args.format,
location=args.location,
dataset=args.dataset,
annotation_group=getattr(args, "annotation_group", None),
name=args.name,
extract_zip=not args.no_extract,
)
with sdk_output_to_stderr(args):
result_path = workspace.search_export(
query=args.query,
format=args.format,
location=args.location,
dataset=args.dataset,
annotation_group=getattr(args, "annotation_group", None),
name=args.name,
extract_zip=not args.no_extract,
)
except Exception as exc:
output_error(args, str(exc))
return
Expand Down
5 changes: 3 additions & 2 deletions roboflow/cli/handlers/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ def _parse_url(url: str) -> tuple:

def _download(args): # noqa: ANN001
import roboflow
from roboflow.cli._output import output, output_error, suppress_sdk_output
from roboflow.cli._output import output, output_error, sdk_output_to_stderr, suppress_sdk_output

w, p, v = _parse_url(args.url_or_id)

Expand All @@ -257,7 +257,8 @@ def _download(args): # noqa: ANN001
else:
version_obj = project.version(int(v))

version_obj.download(args.format, location=args.location, overwrite=True)
with sdk_output_to_stderr(args):
version_obj.download(args.format, location=args.location, overwrite=True)
except SystemExit:
raise
except Exception as exc:
Expand Down
6 changes: 3 additions & 3 deletions roboflow/core/project.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import datetime
import datetime as dt
import json
import mimetypes
import os
Expand Down Expand Up @@ -68,7 +68,7 @@ def __init__(self, api_key: str, a_project: dict, model_format: Optional[str] =
self.annotation = a_project["annotation"]
self.classes = a_project["classes"]
self.colors = a_project["colors"]
self.created = datetime.datetime.fromtimestamp(a_project["created"])
self.created = dt.datetime.fromtimestamp(a_project["created"])
self.id = a_project["id"]
self.images = a_project["images"]
self.name = a_project["name"]
Expand All @@ -77,7 +77,7 @@ def __init__(self, api_key: str, a_project: dict, model_format: Optional[str] =
self.type = a_project["type"]
self.multilabel = a_project.get("multilabel", False)
self.unannotated = a_project["unannotated"]
self.updated = datetime.datetime.fromtimestamp(a_project["updated"])
self.updated = dt.datetime.fromtimestamp(a_project["updated"])
self.model_format = model_format

temp = self.id.rsplit("/")
Expand Down
146 changes: 146 additions & 0 deletions tests/cli/test_json_stdout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"""Under --json, stdout carries only the JSON document; SDK prints go to stderr (#546)."""

import io
import json
import os
import re
import shutil
import tempfile
import zipfile
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch

import responses
from typer.testing import CliRunner

from roboflow.cli import app
from roboflow.config import API_URL
from tests import PROJECT_NAME, PROJECT_VERSION, ROBOFLOW_API_KEY, WORKSPACE_NAME, RoboflowTest

runner = CliRunner()

DOWNLOAD_URL = "https://example.com/export.zip"


def _zip_bytes(files: dict) -> bytes:
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as zf:
for name, content in files.items():
zf.writestr(name, content)
return buffer.getvalue()


class TestJsonStdout(RoboflowTest):
def setUp(self):
super().setUp()
self.tmp = tempfile.mkdtemp()
env = patch.dict(os.environ, {"ROBOFLOW_API_KEY": ROBOFLOW_API_KEY})
env.start()
self.addCleanup(env.stop)
self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)

def _invoke(self, *argv: str):
return runner.invoke(app, ["--api-key", ROBOFLOW_API_KEY, "--workspace", WORKSPACE_NAME, *argv])

def _assert_json_stdout(self, result, expected_stderr: str) -> dict:
self.assertEqual(result.exit_code, 0, msg=result.output)
data = json.loads(result.stdout)
self.assertIn(expected_stderr, result.stderr)
return data

def _mock_search_export(self):
responses.add(
responses.POST,
f"{API_URL}/{WORKSPACE_NAME}/search/export?api_key={ROBOFLOW_API_KEY}",
json={"success": True, "link": "exp1"},
status=202,
)
responses.add(
responses.GET,
f"{API_URL}/{WORKSPACE_NAME}/search/export/exp1?api_key={ROBOFLOW_API_KEY}",
json={"ready": True, "link": DOWNLOAD_URL},
)
responses.add(responses.GET, DOWNLOAD_URL, body=_zip_bytes({"images/a.jpg": "x"}))

def test_search_export(self):
self._mock_search_export()
out = os.path.join(self.tmp, "out")

result = self._invoke("--json", "search", f"project:{PROJECT_NAME}", "--export", "-l", out)

data = self._assert_json_stdout(result, "Export started (id=exp1)")
self.assertEqual(data, {"status": "completed", "path": out})

def test_search_export_text_mode_keeps_sdk_output_on_stdout(self):
self._mock_search_export()
out = os.path.join(self.tmp, "out")

result = self._invoke("search", f"project:{PROJECT_NAME}", "--export", "-l", out)

self.assertEqual(result.exit_code, 0, msg=result.output)
self.assertIn("Export started (id=exp1)", result.stdout)
self.assertIn(f"Export completed: {out}", result.stdout)

def test_image_upload_directory(self):
Path(self.tmp, "a.jpg").write_bytes(b"\xff\xd8\xff\xe0fake-jpeg")
responses.add(
responses.POST, re.compile(rf"{re.escape(API_URL)}/.*upload.*"), json={"success": True, "id": "i1"}
)

result = self._invoke("--json", "image", "upload", self.tmp, "-p", PROJECT_NAME)

data = self._assert_json_stdout(result, "[UPLOADED]")
self.assertEqual(data, {"status": "imported", "path": self.tmp, "count": 1})
self.assertIn(f"Uploading to existing project {WORKSPACE_NAME}/{PROJECT_NAME}", result.stderr)

def test_model_upload(self):
Path(self.tmp, "roboflow_deploy.zip").write_bytes(b"weights")
bundle = SimpleNamespace(model_type="yolov8", archive_path=Path(self.tmp, "roboflow_deploy.zip"))
responses.add(
responses.POST,
re.compile(rf"{re.escape(API_URL)}/{WORKSPACE_NAME}/models/prepareUpload\?.*"),
json={"url": "https://storage.example.com/signed"},
)
responses.add(responses.PUT, "https://storage.example.com/signed")

with (
patch("roboflow.util.model_processor.package_custom_weights_interactive", return_value=bundle),
patch("roboflow.util.model_processor.validate_model_type_for_project"),
):
result = self._invoke("--json", "model", "upload", "-m", self.tmp, "-t", "yolov8", "-p", PROJECT_NAME)

data = self._assert_json_stdout(result, "View the status of your deployment")
self.assertEqual(data, {"status": "uploaded"})
self.assertNotIn("loading Roboflow", result.stdout + result.stderr)

def test_version_download_with_on_demand_export(self):
version_url = f"{API_URL}/{WORKSPACE_NAME}/{PROJECT_NAME}/{PROJECT_VERSION}"
responses.add(
responses.GET,
f"{version_url}?api_key={ROBOFLOW_API_KEY}&nocache=true",
json={"version": {"generating": False, "progress": 1.0, "images": 10}},
)
export_url = f"{version_url}/coco?api_key={ROBOFLOW_API_KEY}&nocache=true"
responses.add(responses.GET, export_url, json={"ready": False, "progress": 0.4})
responses.add(responses.GET, export_url, json={"export": {"link": DOWNLOAD_URL}})
zip_body = _zip_bytes({"README.txt": "x"})
responses.add(responses.GET, DOWNLOAD_URL, body=zip_body, headers={"content-length": str(len(zip_body))})
out = os.path.join(self.tmp, "ds")

with patch("roboflow.core.version.time.sleep"):
result = self._invoke(
"--json",
"version",
"download",
f"{WORKSPACE_NAME}/{PROJECT_NAME}/{PROJECT_VERSION}",
"-f",
"coco",
"-l",
out,
)

data = self._assert_json_stdout(result, "Version export complete for coco format")
self.assertEqual(data["format"], "coco")
self.assertEqual(data["location"], out)
self.assertIn("Exporting format coco in progress : 40.0%", result.stderr)
Loading