Skip to content

Commit 9dad695

Browse files
authored
[PLT-4090] Add batch_ids filter to project.get_overview() (#2060)
1 parent 16c79d7 commit 9dad695

5 files changed

Lines changed: 188 additions & 17 deletions

File tree

libs/labelbox/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
# Changelog
2+
# Version 7.9.0 (Unreleased)
3+
## Added
4+
* Add optional `batch_ids` parameter to `Project.get_overview()` to return workflow state counts scoped to one or more batches ([#TBD](https://github.com/Labelbox/labelbox-python/pull/TBD))
5+
26
# Version 7.8.0 (2026-06-11)
37
## Added
48
* Add `ModelRun.total_cost` and `ModelRun.total_data_rows` properties to retrieve inference cost and data row count for a model run ([#2057](https://github.com/Labelbox/labelbox-python/pull/2057))

libs/labelbox/src/labelbox/schema/project.py

Lines changed: 64 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,20 @@
8686
pass
8787

8888

89+
_MAX_BATCH_IDS = 1000
90+
91+
92+
def _validate_batch_ids(batch_ids: List[str]) -> None:
93+
if not isinstance(batch_ids, list):
94+
raise ValueError("batch_ids filter expects a list.")
95+
if len(batch_ids) == 0:
96+
raise ValueError("batch_ids filter expects a non-empty list.")
97+
if len(batch_ids) > _MAX_BATCH_IDS:
98+
raise ValueError(
99+
f"batch_ids filter only supports a max of {_MAX_BATCH_IDS} items."
100+
)
101+
102+
89103
DataRowPriority = int
90104
LabelingParameterOverrideInput = Tuple[DataRowIdentifier, DataRowPriority]
91105

@@ -1737,48 +1751,82 @@ def __check_data_rows_have_been_processed(
17371751
]
17381752

17391753
def get_overview(
1740-
self, details=False
1754+
self,
1755+
details: bool = False,
1756+
batch_ids: Optional[List[str]] = None,
17411757
) -> Union[ProjectOverview, ProjectOverviewDetailed]:
17421758
"""Return the overview of a project.
17431759
17441760
This method returns the number of data rows per task queue and issues of a project,
17451761
which is equivalent to the Overview tab of a project.
17461762
17471763
Args:
1748-
details (bool, optional): Whether to include detailed queue information for review and rework queues.
1749-
Defaults to False.
1764+
details (bool, optional): Whether to include detailed queue information for
1765+
review and rework queues. Defaults to False.
1766+
batch_ids (Optional[List[str]], optional): When provided, limits counts to data
1767+
rows in the given batch(es). Multiple batch IDs return combined counts, not
1768+
per-batch breakdowns. Unknown or foreign batch IDs return zero counts.
1769+
Defaults to None (project-wide counts).
17501770
17511771
Returns:
1752-
Union[ProjectOverview, ProjectOverviewDetailed]: An object representing the project overview.
1753-
If `details` is False, returns a `ProjectOverview` object.
1754-
If `details` is True, returns a `ProjectOverviewDetailed` object.
1772+
Union[ProjectOverview, ProjectOverviewDetailed]: An object representing the
1773+
project overview. If `details` is False, returns a `ProjectOverview` object.
1774+
If `details` is True, returns a `ProjectOverviewDetailed` object. When
1775+
`batch_ids` is set, `issues` is None because issue counts are not
1776+
batch-scoped.
17551777
17561778
Raises:
1779+
ValueError: If `batch_ids` is an empty list or exceeds the maximum allowed size.
17571780
Exception: If there is an error executing the query.
17581781
17591782
"""
1760-
query = """query ProjectGetOverviewPyApi($projectId: ID!) {
1761-
project(where: { id: $projectId }) {
1762-
workstreamStateCounts {
1783+
if batch_ids is not None:
1784+
_validate_batch_ids(batch_ids)
1785+
1786+
query = """query ProjectGetOverviewPyApi(
1787+
$projectId: ID!,
1788+
$batchIds: [String!],
1789+
$countInput: DataRowCountQueryInput,
1790+
$includeIssues: Boolean!
1791+
) {
1792+
project(where: { id: $projectId }) {
1793+
workstreamStateCounts(batchIds: $batchIds) {
17631794
state
17641795
count
17651796
}
17661797
taskQueues {
17671798
queueType
17681799
name
1769-
dataRowCount
1800+
dataRowCount(input: $countInput)
17701801
}
1771-
issues {
1802+
issues @include(if: $includeIssues) {
17721803
totalCount
17731804
}
17741805
completedDataRowCount
17751806
}
17761807
}
17771808
"""
17781809

1810+
variables: Dict[str, Any] = {
1811+
"projectId": self.uid,
1812+
"batchIds": batch_ids,
1813+
"includeIssues": batch_ids is None,
1814+
}
1815+
if batch_ids is not None:
1816+
variables["countInput"] = {
1817+
"searchQuery": {
1818+
"scope": {"projectId": self.uid},
1819+
"query": [
1820+
{"ids": batch_ids, "operator": "is", "type": "batch"}
1821+
],
1822+
}
1823+
}
1824+
else:
1825+
variables["countInput"] = None
1826+
17791827
# Must use experimental to access "issues"
17801828
result = self.client.execute(
1781-
query, {"projectId": self.uid}, experimental=True
1829+
query, variables, experimental=True
17821830
)["project"]
17831831

17841832
# Reformat category names
@@ -1788,7 +1836,10 @@ def get_overview(
17881836
if st["state"] != "NotInTaskQueue"
17891837
}
17901838

1791-
overview["issues"] = result.get("issues", {}).get("totalCount")
1839+
if batch_ids is None:
1840+
overview["issues"] = result.get("issues", {}).get("totalCount")
1841+
else:
1842+
overview["issues"] = None
17921843

17931844
# Rename categories
17941845
overview["to_label"] = overview.pop("unlabeled")

libs/labelbox/src/labelbox/schema/project_overview.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Dict, List
1+
from typing import Dict, List, Optional
22

33
from pydantic import BaseModel
44
from typing_extensions import TypedDict
@@ -16,6 +16,7 @@ class ProjectOverview(BaseModel):
1616
The `skipped` attribute represents the number of data rows that have been skipped (Skipped).
1717
The `done` attribute represents the number of data rows that have been marked as Done (Done).
1818
The `issues` attribute represents the number of data rows with associated issues (Issues).
19+
When the overview is scoped to one or more batches, `issues` is `None`.
1920
2021
The following don't appear in the UI
2122
The `labeled` attribute represents the number of data rows that have been labeled.
@@ -27,7 +28,7 @@ class ProjectOverview(BaseModel):
2728
in_rework: int
2829
skipped: int
2930
done: int
30-
issues: int
31+
issues: Optional[int] = None
3132
labeled: int
3233
total_data_rows: int
3334

@@ -60,6 +61,7 @@ class ProjectOverviewDetailed(BaseModel):
6061
The `skipped` attribute represents the number of data rows that have been skipped (Skipped).
6162
The `done` attribute represents the number of data rows that have been marked as Done (Done).
6263
The `issues` attribute represents the number of data rows with associated issues (Issues).
64+
When the overview is scoped to one or more batches, `issues` is `None`.
6365
6466
The following don't appear in the UI
6567
The `labeled` attribute represents the number of data rows that have been labeled.
@@ -71,6 +73,6 @@ class ProjectOverviewDetailed(BaseModel):
7173
in_rework: _QueueDetail
7274
skipped: int
7375
done: int
74-
issues: int
76+
issues: Optional[int] = None
7577
labeled: int
7678
total_data_rows: int

libs/labelbox/tests/integration/test_batch.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from uuid import uuid4
33

44
import pytest
5+
import time
56
from lbox.exceptions import (
67
LabelboxError,
78
MalformedQueryException,
@@ -139,6 +140,43 @@ def test_create_batch_with_data_row_class(
139140
assert batch.size == len(data_rows)
140141

141142

143+
def test_get_overview_batch_scoped(project: Project, small_dataset: Dataset):
144+
export_task = small_dataset.export()
145+
export_task.wait_till_done()
146+
stream = export_task.get_buffered_stream()
147+
data_rows = [dr.json["data_row"]["id"] for dr in stream]
148+
149+
batch_a = project.create_batch("batch-a-overview", [data_rows[0]])
150+
batch_b = project.create_batch("batch-b-overview", [data_rows[1]])
151+
152+
timeout_seconds = 60
153+
sleep_time = 2
154+
overview_a = None
155+
overview_b = None
156+
while timeout_seconds > 0:
157+
overview_a = project.get_overview(batch_ids=[batch_a.uid])
158+
overview_b = project.get_overview(batch_ids=[batch_b.uid])
159+
if (
160+
overview_a.total_data_rows == 1
161+
and overview_b.total_data_rows == 1
162+
):
163+
break
164+
timeout_seconds -= sleep_time
165+
time.sleep(sleep_time)
166+
else:
167+
raise AssertionError(
168+
"Timed out waiting for batch-scoped overview counts"
169+
)
170+
171+
assert overview_a.issues is None
172+
assert overview_b.issues is None
173+
assert overview_a.total_data_rows == 1
174+
assert overview_b.total_data_rows == 1
175+
176+
combined = project.get_overview(batch_ids=[batch_a.uid, batch_b.uid])
177+
assert combined.total_data_rows == 2
178+
179+
142180
def test_archive_batch(project: Project, small_dataset: Dataset):
143181
export_task = small_dataset.export()
144182
export_task.wait_till_done()

libs/labelbox/tests/unit/test_project.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import pytest
22
from unittest.mock import MagicMock
33

4-
from labelbox.schema.project import Project
4+
from labelbox.schema.project import Project, _validate_batch_ids
55
from labelbox.schema.ontology_kind import EditorTaskType
66

77

@@ -30,6 +30,82 @@ def project_entity():
3030
)
3131

3232

33+
def _workstream_state_counts_response():
34+
return {
35+
"workstreamStateCounts": [
36+
{"state": "Unlabeled", "count": 10},
37+
{"state": "InReview", "count": 2},
38+
{"state": "InRework", "count": 1},
39+
{"state": "Skipped", "count": 0},
40+
{"state": "Done", "count": 5},
41+
{"state": "Labeled", "count": 8},
42+
{"state": "All", "count": 18},
43+
{"state": "NotInTaskQueue", "count": 3},
44+
],
45+
"taskQueues": [],
46+
"issues": {"totalCount": 4},
47+
"completedDataRowCount": 5,
48+
}
49+
50+
51+
def test_get_overview_project_wide(project_entity):
52+
client = project_entity.client
53+
client.execute.return_value = {"project": _workstream_state_counts_response()}
54+
55+
overview = project_entity.get_overview()
56+
57+
assert overview.to_label == 10
58+
assert overview.total_data_rows == 18
59+
assert overview.issues == 4
60+
61+
args, kwargs = client.execute.call_args
62+
variables = args[1]
63+
assert variables["projectId"] == "test"
64+
assert variables["batchIds"] is None
65+
assert variables["countInput"] is None
66+
assert variables["includeIssues"] is True
67+
assert kwargs["experimental"] is True
68+
query = args[0]
69+
assert "issues @include(if: $includeIssues)" in query
70+
71+
72+
def test_get_overview_batch_scoped(project_entity):
73+
client = project_entity.client
74+
client.execute.return_value = {"project": _workstream_state_counts_response()}
75+
76+
overview = project_entity.get_overview(batch_ids=["batch-1"])
77+
78+
assert overview.issues is None
79+
80+
args, kwargs = client.execute.call_args
81+
variables = args[1]
82+
assert variables["batchIds"] == ["batch-1"]
83+
assert variables["includeIssues"] is False
84+
assert variables["countInput"] == {
85+
"searchQuery": {
86+
"scope": {"projectId": "test"},
87+
"query": [{"ids": ["batch-1"], "operator": "is", "type": "batch"}],
88+
}
89+
}
90+
91+
92+
@pytest.mark.parametrize(
93+
"batch_ids,expected_message",
94+
[
95+
([], "batch_ids filter expects a non-empty list."),
96+
(["batch-1"] * 1001, "batch_ids filter only supports a max of 1000 items."),
97+
],
98+
)
99+
def test_validate_batch_ids_rejects_invalid(batch_ids, expected_message):
100+
with pytest.raises(ValueError, match=expected_message):
101+
_validate_batch_ids(batch_ids)
102+
103+
104+
def test_get_overview_rejects_empty_batch_ids(project_entity):
105+
with pytest.raises(ValueError, match="batch_ids filter expects a non-empty list."):
106+
project_entity.get_overview(batch_ids=[])
107+
108+
33109
@pytest.mark.parametrize(
34110
"api_editor_task_type, expected_editor_task_type",
35111
[

0 commit comments

Comments
 (0)