Skip to content
Open
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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,31 @@ All notable changes to this project will be documented in this file.

## Unreleased

### Fixed

- Dataset, weight, and search export downloads use buffered 64 KiB writes with
byte-based progress, handle missing or invalid Content-Length headers, and detect
truncated responses with both current urllib3 and supported urllib3 1.x versions.
- Local video inference uploads stream from the file instead of loading the entire video into memory.
- `Version.train()` raises on failed training instead of returning a model, including
when stale results from an earlier run are present.
- Vision Events requests follow region and environment changes made after SDK import.
- Weight downloads reject HTTP errors and replace existing weights only after the
complete response has been written successfully.
- Dataset downloads reuse a directory only after download, extraction, and YAML
processing have completed for the same version, format, and API endpoint. Interrupted
SDK downloads can be retried; other non-empty directories require `overwrite=True`.
- Dataset and model uploads propagate failures to SDK and CLI callers. Directory
uploads expose actual counts with `return_summary=True`, preserving the default
`None` result. CLI model uploads reject packaging mismatches without prompting,
and CLI upload output stays valid JSON without buffering SDK logs in memory.
Directory and model upload commands preserve HTTP status codes to distinguish
authentication, missing resources, and other failures.
- COCO imports resolve image paths relative to their annotation file, keeping
identically named images in different splits separate and rejecting ambiguous matches.
- Object detection preserves response coordinates when the input image was not
resized, and rescales boxes using the actual dimensions sent to inference.

### Added

- `Project.autolabel(..., keypoint_confidence_threshold=0.75)` and
Expand Down
2 changes: 1 addition & 1 deletion roboflow/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def check_key(api_key, model, notebook, num_retries=0):
response = http.post(API_URL + "/?api_key=" + api_key)

if response.status_code == 401:
raise RuntimeError(response.text)
raise rfapi.AuthenticationError(response.text, status_code=response.status_code)

if response.status_code != 200:
# retry 5 times
Expand Down
15 changes: 11 additions & 4 deletions roboflow/adapters/rfapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ def __init__(self, message, status_code=None):
self.status_code = status_code


class AuthenticationError(RoboflowError, RuntimeError):
"""API authentication failure, compatible with existing RuntimeError handlers."""


class ImageUploadError(RoboflowError):
def __init__(self, message, status_code=None):
self.message = message
Expand All @@ -49,7 +53,7 @@ def get_workspace(api_key, workspace_url):
url = f"{API_URL}/{workspace_url}?api_key={api_key}"
response = http.get(url)
if response.status_code != 200:
raise RoboflowError(response.text)
raise RoboflowError(response.text, status_code=response.status_code)
result = response.json()
return result

Expand Down Expand Up @@ -1055,7 +1059,7 @@ def init_zip_upload(
body["batchName"] = batch_name
response = http.post(url, params={"api_key": api_key}, json=body)
if response.status_code not in (200, 201):
raise RoboflowError(response.text)
raise RoboflowError(response.text, status_code=response.status_code)
return response.json()


Expand All @@ -1069,15 +1073,18 @@ def upload_zip_to_signed_url(signed_url, zip_path) -> None:
timeout=(60, 3600),
)
if not response.ok:
raise RoboflowError(f"Zip upload to signed URL failed ({response.status_code}): {response.text}")
raise RoboflowError(
f"Zip upload to signed URL failed ({response.status_code}): {response.text}",
status_code=response.status_code,
)


def get_zip_upload_status(api_key, workspace_url, task_id) -> dict:
"""GET /{ws}/upload/zip/{task_id} — poll status of an async zip upload."""
url = f"{API_URL}/{workspace_url}/upload/zip/{task_id}"
response = http.get(url, params={"api_key": api_key})
if response.status_code != 200:
raise RoboflowError(response.text)
raise RoboflowError(response.text, status_code=response.status_code)
return response.json()


Expand Down
22 changes: 10 additions & 12 deletions roboflow/adapters/vision_events_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@
from roboflow.config import API_URL
from roboflow.util import http

_BASE = f"{API_URL}/vision-events"


def _auth_headers(api_key: str) -> Dict[str, str]:
return {"Authorization": f"Bearer {api_key}"}
Expand All @@ -28,7 +26,7 @@ def write_event(api_key: str, event: Dict[str, Any]) -> dict:
Raises:
RoboflowError: On non-201 response status codes.
"""
response = http.post(_BASE, json=event, headers=_auth_headers(api_key))
response = http.post(f"{API_URL}/vision-events", json=event, headers=_auth_headers(api_key))
if response.status_code != 201:
raise RoboflowError(response.text)
return response.json()
Expand All @@ -48,7 +46,7 @@ def write_batch(api_key: str, events: List[Dict[str, Any]]) -> dict:
RoboflowError: On non-201 response status codes.
"""
response = http.post(
f"{_BASE}/batch",
f"{API_URL}/vision-events/batch",
json={"events": events},
headers=_auth_headers(api_key),
)
Expand All @@ -73,7 +71,7 @@ def query(api_key: str, query_params: Dict[str, Any]) -> dict:
RoboflowError: On non-200 response status codes.
"""
response = http.post(
f"{_BASE}/query",
f"{API_URL}/vision-events/query",
json=query_params,
headers=_auth_headers(api_key),
)
Expand All @@ -99,7 +97,7 @@ def list_use_cases(api_key: str, status: Optional[str] = None) -> dict:
if status is not None:
params["status"] = status
response = http.get(
f"{_BASE}/use-cases",
f"{API_URL}/vision-events/use-cases",
params=params,
headers=_auth_headers(api_key),
)
Expand All @@ -122,7 +120,7 @@ def get_custom_metadata_schema(api_key: str, use_case_id: str) -> dict:
RoboflowError: On non-200 response status codes.
"""
response = http.get(
f"{_BASE}/custom-metadata-schema/{use_case_id}",
f"{API_URL}/vision-events/custom-metadata-schema/{use_case_id}",
headers=_auth_headers(api_key),
)
if response.status_code != 200:
Expand All @@ -144,7 +142,7 @@ def create_use_case(api_key: str, name: str) -> dict:
RoboflowError: On non-201 response status codes.
"""
response = http.post(
f"{_BASE}/use-cases",
f"{API_URL}/vision-events/use-cases",
json={"name": name},
headers=_auth_headers(api_key),
)
Expand All @@ -168,7 +166,7 @@ def rename_use_case(api_key: str, use_case_id: str, name: str) -> dict:
RoboflowError: On non-200 response status codes.
"""
response = http.put(
f"{_BASE}/use-cases/{use_case_id}",
f"{API_URL}/vision-events/use-cases/{use_case_id}",
json={"name": name},
headers=_auth_headers(api_key),
)
Expand All @@ -191,7 +189,7 @@ def archive_use_case(api_key: str, use_case_id: str) -> dict:
RoboflowError: On non-200 response status codes.
"""
response = http.post(
f"{_BASE}/use-cases/{use_case_id}/archive",
f"{API_URL}/vision-events/use-cases/{use_case_id}/archive",
headers=_auth_headers(api_key),
)
if response.status_code != 200:
Expand All @@ -213,7 +211,7 @@ def unarchive_use_case(api_key: str, use_case_id: str) -> dict:
RoboflowError: On non-200 response status codes.
"""
response = http.post(
f"{_BASE}/use-cases/{use_case_id}/unarchive",
f"{API_URL}/vision-events/use-cases/{use_case_id}/unarchive",
headers=_auth_headers(api_key),
)
if response.status_code != 200:
Expand Down Expand Up @@ -253,7 +251,7 @@ def upload_image(
m = MultipartEncoder(fields=fields)
headers = _auth_headers(api_key)
headers["Content-Type"] = m.content_type
response = http.post(f"{_BASE}/upload", data=m, headers=headers)
response = http.post(f"{API_URL}/vision-events/upload", data=m, headers=headers)

if response.status_code != 201:
raise RoboflowError(response.text)
Expand Down
10 changes: 6 additions & 4 deletions roboflow/cli/_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
from __future__ import annotations

import contextlib
import io
import json
import os
import sys
from typing import Any, Iterator, Optional

Expand Down Expand Up @@ -234,8 +234,8 @@ def output_api_error(
) -> None:
"""Render a server-side error and exit with the correct code.

Maps an exception's HTTP status (carried as ``exc.status_code`` when the
raiser sets it — see ``_raise_for_trash_response`` in ``adapters.rfapi``)
Maps an exception's HTTP status (carried as ``exc.status_code`` or
``exc.response.status_code`` for requests HTTP errors)
to the per-CONTRIBUTING.md exit-code contract:

* **401** → exit code 2 ("auth error"). ``auth_hint`` overrides ``hint``
Expand All @@ -253,6 +253,8 @@ def output_api_error(
whether to retry vs. re-auth.
"""
status = getattr(exc, "status_code", None)
if status is None:
status = getattr(getattr(exc, "response", None), "status_code", None)
if status == 401:
output_error(
args,
Expand All @@ -279,5 +281,5 @@ def suppress_sdk_output(args: Any = None) -> Iterator[None]:
are not useful CLI output in any mode. The CLI controls its own
output via ``output()`` and ``output_error()``.
"""
with contextlib.redirect_stdout(io.StringIO()):
with open(os.devnull, "w", encoding="utf-8") as sink, contextlib.redirect_stdout(sink):
yield
52 changes: 24 additions & 28 deletions roboflow/cli/handlers/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,18 +356,16 @@ def _handle_upload_single(args, api_key: str, path: str) -> None: # noqa: ANN00


def _handle_upload_directory(args, api_key: str, path: str) -> None: # noqa: ANN001
import os

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

# Always suppress SDK "loading..." noise during workspace init
with suppress_sdk_output():
try:
rf = roboflow.Roboflow(api_key)
workspace = rf.workspace(args.workspace)
except Exception as exc:
output_error(args, str(exc), exit_code=3)
output_api_error(args, exc, hint="Check your workspace and API key.")
return

retries = getattr(args, "retries", None) or getattr(args, "num_retries", 0) or 0
Expand All @@ -376,25 +374,30 @@ 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 suppress_sdk_output():
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,
return_summary=True,
)
except Exception as exc:
output_error(args, str(exc))
output_api_error(args, exc, hint="Check the failed files and retry the upload.")
return

if isinstance(result, dict):
if "uploaded" not in result:
status = result.get("status", "unknown")
if status not in {"completed", "pending"}:
output_error(args, f"Zip upload {status}: {result}", hint="Check the upload task and retry.")
return
data = {
"status": status,
"task_id": result.get("task_id") or result.get("taskId"),
Expand All @@ -405,15 +408,8 @@ def _handle_upload_directory(args, api_key: str, path: str) -> None: # noqa: AN
output(args, data, text=f"Imported {path} to {args.project} (zip upload, status={status})")
return

# Per-image fallback — count files via image extensions
count = 0
image_exts = {".jpg", ".jpeg", ".png", ".bmp", ".gif", ".tiff", ".webp"}
for root, _dirs, files in os.walk(path):
for f in files:
if os.path.splitext(f)[1].lower() in image_exts:
count += 1

data = {"status": "imported", "path": path, "count": count}
count = result["uploaded"] + result["duplicates"]
data = {"status": "imported", "path": path, "count": count, **result}
output(args, data, text=f"Imported {count} images from {path} to {args.project}")


Expand Down
64 changes: 30 additions & 34 deletions roboflow/cli/handlers/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,42 +311,38 @@ 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_api_error, output_error, suppress_sdk_output
from roboflow.config import load_roboflow_api_key

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

if args.version_number is not None:
# Deploy to a specific version
project_id = args.project[0] if isinstance(args.project, list) else args.project
if not project_id:
output_error(args, "Project is required for model upload.", hint="Use -p/--project.")
return
if not args.project:
output_error(args, "At least one project is required.", hint="Use -p/--project.")
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))
except Exception as exc:
output_error(args, str(exc))
return
else:
# Deploy to multiple projects
if not args.project:
output_error(args, "At least one project is required.", hint="Use -p/--project.")
return
api_key = args.api_key or load_roboflow_api_key(args.workspace)
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:
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
try:
with suppress_sdk_output():
rf = roboflow.Roboflow(api_key=api_key)
workspace = rf.workspace(args.workspace)
if args.version_number is not None:
project_id = args.project[0] if isinstance(args.project, list) else args.project
project = workspace.project(project_id)
version = project.version(args.version_number)
version.deploy(str(args.model_type), str(args.model_path), str(args.filename), interactive=False)
else:
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),
interactive=False,
)
except Exception as exc:
output_api_error(args, exc, hint="Check the model files and project permissions, then retry.")
return

output(args, {"status": "uploaded"}, text="Model uploaded successfully.")
Loading