Skip to content
Merged
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
11 changes: 11 additions & 0 deletions roboflow/adapters/rfapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@ def get_project(api_key, workspace_url, project_url):
return result


def get_project_health(api_key, workspace_url, project_url, regenerate=False):
"""GET /{workspace}/{project}/health — dataset health check statistics."""
url = f"{API_URL}/{workspace_url}/{project_url}/health?api_key={api_key}"
if regenerate:
url += "&regenerate=true"
response = requests.get(url)
if response.status_code != 200:
raise RoboflowError(response.text)
return response.json()


def start_version_training(
api_key: str,
workspace_url: str,
Expand Down
38 changes: 37 additions & 1 deletion roboflow/cli/handlers/project.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Project management commands: list, get, create."""
"""Project management commands: list, get, create, health."""

from __future__ import annotations

Expand Down Expand Up @@ -78,6 +78,19 @@ def restore_project(
_restore_project(args)


@project_app.command("health")
def health_project(
ctx: typer.Context,
project_id: Annotated[str, typer.Argument(help="Project ID or shorthand (e.g. my-ws/my-project)")],
regenerate: Annotated[
bool, typer.Option("--regenerate", "-r", help="Force regeneration of health check data.")
] = False,
) -> None:
"""Show dataset health check for a project (class balance, dimensions, splits)."""
args = ctx_to_args(ctx, project_id=project_id, regenerate=regenerate)
_health_project(args)


# ---------------------------------------------------------------------------
# Business logic (unchanged from argparse version)
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -337,3 +350,26 @@ def _restore_project(args): # noqa: ANN001
return

output(args, data, text=f"Restored {workspace_url}/{project_slug} from Trash.")


def _health_project(args): # noqa: ANN001
import json

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

with suppress_sdk_output(args):
try:
rf = roboflow.Roboflow(api_key=args.api_key)
project = rf.workspace(args.workspace).project(args.project_id)
except Exception as exc:
output_error(args, str(exc))
return

try:
data = project.health(regenerate=args.regenerate)
except Exception as exc:
output_error(args, str(exc), exit_code=3)
return

output(args, data, text=json.dumps(data, indent=2))
18 changes: 18 additions & 0 deletions roboflow/core/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -1084,3 +1084,21 @@ def restore(self):
if not match:
raise RuntimeError(f"Project '{self.__project_name}' is not in Trash — nothing to restore.")
return rfapi.restore_trash_item(self.__api_key, self.__workspace, "project", match["id"])

def health(self, regenerate: bool = False) -> Dict:
"""Get health check statistics for this project.

Args:
regenerate: If True, force regeneration of health check data.

Returns:
Dict: Health check statistics including class balance,
image dimensions, annotation counts, and split distribution.

Example:
>>> import roboflow
>>> rf = roboflow.Roboflow(api_key="YOUR_API_KEY")
>>> project = rf.workspace().project("PROJECT_ID")
>>> health = project.health()
"""
return rfapi.get_project_health(self.__api_key, self.__workspace, self.__project_name, regenerate=regenerate)
51 changes: 51 additions & 0 deletions tests/cli/test_project_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,5 +124,56 @@ def test_restore_not_in_trash(self) -> None:
mock_restore.assert_not_called()


class TestProjectHealthHandler(unittest.TestCase):
"""project health calls project.health() via SDK."""

def _args(self, project_id="my-project", regenerate=False):
from argparse import Namespace

return Namespace(
json=False,
workspace="my-ws",
api_key="fake-key",
quiet=False,
project_id=project_id,
regenerate=regenerate,
)

def test_health_exists(self) -> None:
result = runner.invoke(app, ["project", "health", "--help"])
self.assertEqual(result.exit_code, 0)
self.assertIn("regenerate", result.output.lower())

def test_health_calls_sdk(self) -> None:
from unittest.mock import MagicMock, patch

from roboflow.cli.handlers.project import _health_project

mock_project = MagicMock()
mock_project.health.return_value = {"images": 100, "classes": {"cat": 50, "dog": 50}}

mock_rf = MagicMock()
mock_rf.workspace.return_value.project.return_value = mock_project

with patch("roboflow.Roboflow", return_value=mock_rf):
_health_project(self._args())
mock_project.health.assert_called_once_with(regenerate=False)

def test_health_regenerate(self) -> None:
from unittest.mock import MagicMock, patch

from roboflow.cli.handlers.project import _health_project

mock_project = MagicMock()
mock_project.health.return_value = {"images": 100}

mock_rf = MagicMock()
mock_rf.workspace.return_value.project.return_value = mock_project

with patch("roboflow.Roboflow", return_value=mock_rf):
_health_project(self._args(regenerate=True))
mock_project.health.assert_called_once_with(regenerate=True)


if __name__ == "__main__":
unittest.main()
Loading