diff --git a/README.md b/README.md index 6261a9e..2c74433 100644 --- a/README.md +++ b/README.md @@ -2665,6 +2665,17 @@ annotation_id = client.create_annotation( project="YOUR_PROJECT_SLUG", type="bbox", value="cat", title="Cat", color="#FF0000", attributes=attributes) ``` +Create a new segmentation annotation that allows disjoint regions. + +`max_area_count` is the maximum number of separate regions a single segmentation annotation may +consist of, between 1 and 1000. It only applies to segmentation classes and defaults to `1`, which +disallows disjoint regions. Set `None` to allow any number of regions. + +```python +annotation_id = client.create_annotation( + project="YOUR_PROJECT_SLUG", type="segmentation", value="cat", title="Cat", max_area_count=None) +``` + Create a new classification annotation. ```python @@ -2831,6 +2842,16 @@ annotation_id = client.update_annotation( annotation_id="YOUR_ANNOTATION_ID", value="cat2", title="Cat2", color="#FF0000", attributes=attributes) ``` +Update the maximum number of regions of a segmentation annotation. + +`max_area_count` accepts a value between 1 and 1000 and is left unchanged when omitted. Set `None` +to allow any number of regions. + +```python +annotation_id = client.update_annotation( + annotation_id="YOUR_ANNOTATION_ID", max_area_count=None) +``` + Update a classification annotation. ```python diff --git a/fastlabel/__init__.py b/fastlabel/__init__.py index fa28612..11e40dd 100644 --- a/fastlabel/__init__.py +++ b/fastlabel/__init__.py @@ -39,6 +39,38 @@ ) +class _Unset: + """Marker for arguments the caller did not pass. + + Most optional arguments here have two states and None covers the second + one: the field is either sent or left out. maxAreaCount has three, because + the API represents "unlimited" as null rather than as 0, so null is itself + a value that has to reach the API -- left out, sent as null, sent as a + number. None is taken up by that null and cannot also mean "the caller + said nothing", so this marker carries that state instead and the argument + is read by identity rather than by truthiness. + + A single shared instance, so that the identity checks that read this + marker still hold after it has been copied or serialised on its way + through caller code. + """ + + _instance: Optional["_Unset"] = None + + def __new__(cls) -> "_Unset": + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __repr__(self) -> str: + return "UNSET" + + +# Typed as Any so that the marker stays out of the public signatures that use it +# as their default. Callers only ever pass an int or None. +_UNSET: Any = _Unset() + + class Client: api = None @@ -4297,6 +4329,7 @@ def create_annotation( color: str = None, order: int = None, attributes: list = [], + max_area_count: Optional[int] = _UNSET, ) -> str: """ Create an annotation. @@ -4308,6 +4341,11 @@ def create_annotation( title is a display name of value (Required). color is hex color code like #ffffff (Optional). attributes is a list of attribute (Optional). + max_area_count is the maximum number of separate regions a single + segmentation annotation may consist of, between 1 and 1000 (Optional). + It only applies to segmentation classes. When omitted the API applies + its default of 1, which disallows disjoint regions. Set None to allow + any number of regions. """ endpoint = "annotations" payload = { @@ -4322,6 +4360,13 @@ def create_annotation( payload["order"] = order if attributes: payload["attributes"] = attributes + # maxAreaCount represents "unlimited" as null rather than as 0, so + # passing None is itself a meaningful call and None cannot double as + # "not passed" the way it does for the other optional arguments. The + # three states are read by identity: left out keeps the API's default, + # None removes the limit, an int sets it. + if max_area_count is not _UNSET: + payload["maxAreaCount"] = max_area_count return self.api.post_request(endpoint, payload=payload) def create_classification_annotation(self, project: str, attributes: list) -> str: @@ -4343,6 +4388,7 @@ def update_annotation( color: str = None, order: int = None, attributes: list = [], + max_area_count: Optional[int] = _UNSET, ) -> str: """ Update an annotation. @@ -4352,6 +4398,10 @@ def update_annotation( title is a display name of value (Optional). color is hex color code like #ffffff (Optional). attributes is a list of attribute (Optional). + max_area_count is the maximum number of separate regions a single + segmentation annotation may consist of, between 1 and 1000 (Optional). + It only applies to segmentation classes and is left unchanged when + omitted. Set None to allow any number of regions. """ endpoint = "annotations/" + annotation_id payload = {} @@ -4365,6 +4415,13 @@ def update_annotation( payload["order"] = order if attributes: payload["attributes"] = attributes + # maxAreaCount represents "unlimited" as null rather than as 0, so + # passing None is itself a meaningful call and None cannot double as + # "not passed" the way it does for the other optional arguments. The + # three states are read by identity: left out keeps the stored value, + # None removes the limit, an int sets it. + if max_area_count is not _UNSET: + payload["maxAreaCount"] = max_area_count return self.api.put_request(endpoint, payload=payload) def update_classification_annotation( diff --git a/tests/conftest.py b/tests/conftest.py index 7e18f89..140f95a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,8 @@ import numpy as np import pytest +import fastlabel + def _write_synthetic_video( path: Path, @@ -48,3 +50,26 @@ def _factory( ) return _factory + + +@pytest.fixture +def client(monkeypatch): + monkeypatch.setenv("FASTLABEL_ACCESS_TOKEN", "dummy-token") + return fastlabel.Client() + + +@pytest.fixture +def capture_request(monkeypatch): + """Replace an api.*_request method with a recorder and return the calls list.""" + + def _factory(client, method_name, return_value=None): + calls = [] + + def fake(endpoint, *args, **kwargs): + calls.append({"endpoint": endpoint, "args": args, "kwargs": kwargs}) + return return_value + + monkeypatch.setattr(client.api, method_name, fake) + return calls + + return _factory diff --git a/tests/test_annotation.py b/tests/test_annotation.py new file mode 100644 index 0000000..492c18b --- /dev/null +++ b/tests/test_annotation.py @@ -0,0 +1,119 @@ +"""Tests for the annotation class API client methods. + +These verify that create_annotation and update_annotation build the correct +endpoint and payload, with a focus on max_area_count. The HTTP layer +(client.api.*_request) is stubbed so no real request is made. +""" + +import copy +import pickle + +import pytest + +import fastlabel + +# --- create_annotation ----------------------------------------------------- + + +def test_create_annotation_omits_max_area_count_by_default(client, capture_request): + calls = capture_request(client, "post_request", return_value="anno-id") + + client.create_annotation( + project="my-project", type="segmentation", value="cat", title="Cat" + ) + + # The field is left out entirely so the API applies its own default of 1 + assert calls[0]["endpoint"] == "annotations" + assert calls[0]["kwargs"]["payload"] == { + "project": "my-project", + "type": "segmentation", + "value": "cat", + "title": "Cat", + } + + +def test_create_annotation_with_max_area_count(client, capture_request): + calls = capture_request(client, "post_request", return_value="anno-id") + + client.create_annotation( + project="my-project", + type="segmentation", + value="cat", + title="Cat", + max_area_count=10, + ) + + assert calls[0]["kwargs"]["payload"]["maxAreaCount"] == 10 + + +def test_create_annotation_without_max_area_count_limit(client, capture_request): + calls = capture_request(client, "post_request", return_value="anno-id") + + client.create_annotation( + project="my-project", + type="segmentation", + value="cat", + title="Cat", + max_area_count=None, + ) + + # None is sent as an explicit null, which means no limit on the server side + assert calls[0]["kwargs"]["payload"]["maxAreaCount"] is None + + +# --- update_annotation ----------------------------------------------------- + + +def test_update_annotation_omits_max_area_count_by_default(client, capture_request): + calls = capture_request(client, "put_request", return_value="anno-id") + + client.update_annotation(annotation_id="anno-id", title="Cat") + + assert calls[0]["endpoint"] == "annotations/anno-id" + assert calls[0]["kwargs"]["payload"] == {"title": "Cat"} + + +def test_update_annotation_with_max_area_count(client, capture_request): + calls = capture_request(client, "put_request", return_value="anno-id") + + client.update_annotation(annotation_id="anno-id", max_area_count=10) + + assert calls[0]["kwargs"]["payload"] == {"maxAreaCount": 10} + + +def test_update_annotation_without_max_area_count_limit(client, capture_request): + calls = capture_request(client, "put_request", return_value="anno-id") + + client.update_annotation(annotation_id="anno-id", max_area_count=None) + + # None is sent as an explicit null, which means no limit on the server side + assert calls[0]["kwargs"]["payload"] == {"maxAreaCount": None} + + +# --- the "not passed" marker ----------------------------------------------- + + +@pytest.mark.parametrize( + "round_trip", + [copy.deepcopy, lambda value: pickle.loads(pickle.dumps(value))], + ids=["deepcopy", "pickle"], +) +def test_create_annotation_keeps_marker_meaning_after_round_trip( + client, capture_request, round_trip +): + calls = capture_request(client, "post_request", return_value="anno-id") + + # Callers that collect keyword arguments and pass them around must still + # get "omitted" out of the default, which relies on the marker's identity + kwargs = round_trip( + { + "project": "my-project", + "type": "segmentation", + "value": "cat", + "title": "Cat", + "max_area_count": fastlabel._UNSET, + } + ) + client.create_annotation(**kwargs) + + assert "maxAreaCount" not in calls[0]["kwargs"]["payload"] diff --git a/tests/test_workspace_user.py b/tests/test_workspace_user.py index e7729ee..d437286 100644 --- a/tests/test_workspace_user.py +++ b/tests/test_workspace_user.py @@ -9,30 +9,11 @@ import fastlabel - -@pytest.fixture -def client(monkeypatch): - monkeypatch.setenv("FASTLABEL_ACCESS_TOKEN", "dummy-token") - return fastlabel.Client() - - -def _capture(monkeypatch, client, method_name, return_value=None): - """Replace an api.*_request method with a recorder and return the calls list.""" - calls = [] - - def fake(endpoint, *args, **kwargs): - calls.append({"endpoint": endpoint, "args": args, "kwargs": kwargs}) - return return_value - - monkeypatch.setattr(client.api, method_name, fake) - return calls - - # --- get_workspace_users --------------------------------------------------- -def test_get_workspace_users_default(monkeypatch, client): - calls = _capture(monkeypatch, client, "get_request", return_value=[]) +def test_get_workspace_users_default(client, capture_request): + calls = capture_request(client, "get_request", return_value=[]) client.get_workspace_users() @@ -41,8 +22,8 @@ def test_get_workspace_users_default(monkeypatch, client): assert calls[0]["kwargs"]["params"] == {"limit": 20} -def test_get_workspace_users_with_params(monkeypatch, client): - calls = _capture(monkeypatch, client, "get_request", return_value=[]) +def test_get_workspace_users_with_params(client, capture_request): + calls = capture_request(client, "get_request", return_value=[]) client.get_workspace_users(keyword="john", offset=10, limit=50) @@ -53,8 +34,8 @@ def test_get_workspace_users_with_params(monkeypatch, client): } -def test_get_workspace_users_offset_zero_included(monkeypatch, client): - calls = _capture(monkeypatch, client, "get_request", return_value=[]) +def test_get_workspace_users_offset_zero_included(client, capture_request): + calls = capture_request(client, "get_request", return_value=[]) client.get_workspace_users(offset=0) @@ -65,8 +46,8 @@ def test_get_workspace_users_offset_zero_included(monkeypatch, client): # --- create_workspace_user ------------------------------------------------- -def test_create_workspace_user_without_modules(monkeypatch, client): - calls = _capture(monkeypatch, client, "post_request", return_value={}) +def test_create_workspace_user_without_modules(client, capture_request): + calls = capture_request(client, "post_request", return_value={}) client.create_workspace_user( name="John Doe", @@ -87,8 +68,8 @@ def test_create_workspace_user_without_modules(monkeypatch, client): # --- update_workspace_user ------------------------------------------------- -def test_update_workspace_user_role(monkeypatch, client): - calls = _capture(monkeypatch, client, "put_request", return_value={}) +def test_update_workspace_user_role(client, capture_request): + calls = capture_request(client, "put_request", return_value={}) client.update_workspace_user(email="john@example.com", role="owner") @@ -102,9 +83,9 @@ def test_update_workspace_user_role(monkeypatch, client): # --- delete_workspace_user ------------------------------------------------- -def test_delete_workspace_user(monkeypatch, client): +def test_delete_workspace_user(client, capture_request): # deletion is performed via PUT with role='none' (no DELETE endpoint) - calls = _capture(monkeypatch, client, "put_request", return_value=None) + calls = capture_request(client, "put_request", return_value=None) result = client.delete_workspace_user(email="john@example.com") @@ -127,8 +108,10 @@ def test_delete_workspace_user(monkeypatch, client): ("modelDev", "function-resource-permissions/model-dev/internal-users"), ], ) -def test_create_module_permissions_single(monkeypatch, client, module, expected_path): - calls = _capture(monkeypatch, client, "post_request", return_value=module) +def test_create_module_permissions_single( + client, capture_request, module, expected_path +): + calls = capture_request(client, "post_request", return_value=module) # a single module string is accepted (not only a list) result = client.create_workspace_user_module_permissions( @@ -141,8 +124,8 @@ def test_create_module_permissions_single(monkeypatch, client, module, expected_ assert result == [module] -def test_create_module_permissions_multiple(monkeypatch, client): - calls = _capture(monkeypatch, client, "post_request", return_value="ok") +def test_create_module_permissions_multiple(client, capture_request): + calls = capture_request(client, "post_request", return_value="ok") result = client.create_workspace_user_module_permissions( email="john@example.com", modules=["annotation", "dataset"] @@ -156,8 +139,8 @@ def test_create_module_permissions_multiple(monkeypatch, client): assert result == ["ok", "ok"] -def test_create_module_permissions_invalid_module(monkeypatch, client): - _capture(monkeypatch, client, "post_request", return_value=None) +def test_create_module_permissions_invalid_module(client, capture_request): + capture_request(client, "post_request", return_value=None) with pytest.raises(fastlabel.exceptions.FastLabelInvalidException): client.create_workspace_user_module_permissions( @@ -168,8 +151,8 @@ def test_create_module_permissions_invalid_module(monkeypatch, client): # --- delete_workspace_user_module_permissions ------------------------------ -def test_delete_module_permissions_single(monkeypatch, client): - calls = _capture(monkeypatch, client, "delete_request", return_value=None) +def test_delete_module_permissions_single(client, capture_request): + calls = capture_request(client, "delete_request", return_value=None) client.delete_workspace_user_module_permissions( email="john@example.com", modules="modelDev" @@ -183,8 +166,8 @@ def test_delete_module_permissions_single(monkeypatch, client): } -def test_delete_module_permissions_multiple(monkeypatch, client): - calls = _capture(monkeypatch, client, "delete_request", return_value=None) +def test_delete_module_permissions_multiple(client, capture_request): + calls = capture_request(client, "delete_request", return_value=None) client.delete_workspace_user_module_permissions( email="john@example.com", modules=["annotation", "modelDev"] @@ -197,8 +180,8 @@ def test_delete_module_permissions_multiple(monkeypatch, client): assert all(c["endpoint"] == "function-resource-permissions" for c in calls) -def test_delete_module_permissions_invalid_module(monkeypatch, client): - _capture(monkeypatch, client, "delete_request", return_value=None) +def test_delete_module_permissions_invalid_module(client, capture_request): + capture_request(client, "delete_request", return_value=None) with pytest.raises(fastlabel.exceptions.FastLabelInvalidException): client.delete_workspace_user_module_permissions(