From 88eba82c8bf10ce99a68958b750217ac9c728cba Mon Sep 17 00:00:00 2001 From: soymd Date: Wed, 19 Aug 2026 13:34:47 +0900 Subject: [PATCH 01/11] =?UTF-8?q?=E3=82=A2=E3=83=8E=E3=83=86=E3=83=BC?= =?UTF-8?q?=E3=82=B7=E3=83=A7=E3=83=B3=E3=82=AF=E3=83=A9=E3=82=B9=E4=BD=9C?= =?UTF-8?q?=E6=88=90=E3=81=A7=E9=A0=98=E5=9F=9F=E6=95=B0=E4=B8=8A=E9=99=90?= =?UTF-8?q?=E3=82=92=E6=8C=87=E5=AE=9A=E3=81=A7=E3=81=8D=E3=82=8B=E3=82=88?= =?UTF-8?q?=E3=81=86=E3=81=AB=E3=81=97=E3=81=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 11 ++++++ fastlabel/__init__.py | 6 ++++ tests/test_annotation.py | 77 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 tests/test_annotation.py diff --git a/README.md b/README.md index 6261a9e..39136ea 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. 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 diff --git a/fastlabel/__init__.py b/fastlabel/__init__.py index fa28612..ef4b585 100644 --- a/fastlabel/__init__.py +++ b/fastlabel/__init__.py @@ -4297,6 +4297,7 @@ def create_annotation( color: str = None, order: int = None, attributes: list = [], + max_area_count: Optional[int] = 1, ) -> str: """ Create an annotation. @@ -4308,6 +4309,10 @@ 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 and defaults to 1, which + disallows disjoint regions. Set None to allow any number of regions. """ endpoint = "annotations" payload = { @@ -4315,6 +4320,7 @@ def create_annotation( "type": type, "value": value, "title": title, + "maxAreaCount": max_area_count, } if color: payload["color"] = color diff --git a/tests/test_annotation.py b/tests/test_annotation.py new file mode 100644 index 0000000..43baa43 --- /dev/null +++ b/tests/test_annotation.py @@ -0,0 +1,77 @@ +"""Tests for the annotation class API client methods. + +These verify that create_annotation builds 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 pytest + +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 + + +# --- create_annotation ----------------------------------------------------- + + +def test_create_annotation_defaults_max_area_count_to_one(monkeypatch, client): + calls = _capture(monkeypatch, client, "post_request", return_value="anno-id") + + client.create_annotation( + project="my-project", type="segmentation", value="cat", title="Cat" + ) + + assert calls[0]["endpoint"] == "annotations" + assert calls[0]["kwargs"]["payload"] == { + "project": "my-project", + "type": "segmentation", + "value": "cat", + "title": "Cat", + "maxAreaCount": 1, + } + + +def test_create_annotation_with_max_area_count(monkeypatch, client): + calls = _capture(monkeypatch, 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(monkeypatch, client): + calls = _capture(monkeypatch, 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 From bd6216df7d52be047a2ae19e5ca70dc1be5a5344 Mon Sep 17 00:00:00 2001 From: soymd Date: Wed, 19 Aug 2026 13:35:58 +0900 Subject: [PATCH 02/11] =?UTF-8?q?=E3=82=A2=E3=83=8E=E3=83=86=E3=83=BC?= =?UTF-8?q?=E3=82=B7=E3=83=A7=E3=83=B3=E3=82=AF=E3=83=A9=E3=82=B9=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E3=81=A7=E9=A0=98=E5=9F=9F=E6=95=B0=E4=B8=8A=E9=99=90?= =?UTF-8?q?=E3=82=92=E5=A4=89=E6=9B=B4=E3=81=A7=E3=81=8D=E3=82=8B=E3=82=88?= =?UTF-8?q?=E3=81=86=E3=81=AB=E3=81=97=E3=81=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 9 +++++++++ fastlabel/__init__.py | 21 +++++++++++++++++++++ tests/test_annotation.py | 35 ++++++++++++++++++++++++++++++++--- 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 39136ea..c5c1b72 100644 --- a/README.md +++ b/README.md @@ -2842,6 +2842,15 @@ 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` 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 ef4b585..fc59e01 100644 --- a/fastlabel/__init__.py +++ b/fastlabel/__init__.py @@ -39,6 +39,20 @@ ) +class _Unset: + """Marker for arguments the caller did not pass. + + Needed where None is a meaningful value that has to be sent to the API, + and therefore cannot double as "leave this field untouched". + """ + + def __repr__(self) -> str: + return "UNSET" + + +_UNSET = _Unset() + + class Client: api = None @@ -4349,6 +4363,7 @@ def update_annotation( color: str = None, order: int = None, attributes: list = [], + max_area_count: Union[int, None, _Unset] = _UNSET, ) -> str: """ Update an annotation. @@ -4358,6 +4373,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 = {} @@ -4371,6 +4390,8 @@ def update_annotation( payload["order"] = order if attributes: payload["attributes"] = attributes + if not isinstance(max_area_count, _Unset): + payload["maxAreaCount"] = max_area_count return self.api.put_request(endpoint, payload=payload) def update_classification_annotation( diff --git a/tests/test_annotation.py b/tests/test_annotation.py index 43baa43..9dda849 100644 --- a/tests/test_annotation.py +++ b/tests/test_annotation.py @@ -1,8 +1,8 @@ """Tests for the annotation class API client methods. -These verify that create_annotation builds 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. +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 pytest @@ -75,3 +75,32 @@ def test_create_annotation_without_max_area_count_limit(monkeypatch, client): # 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(monkeypatch, client): + calls = _capture(monkeypatch, 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(monkeypatch, client): + calls = _capture(monkeypatch, 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(monkeypatch, client): + calls = _capture(monkeypatch, 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} From bcb88fda0f44fbbd87e0145438d8e99e63cd2b9e Mon Sep 17 00:00:00 2001 From: soymd Date: Wed, 19 Aug 2026 15:50:21 +0900 Subject: [PATCH 03/11] =?UTF-8?q?=E9=A0=98=E5=9F=9F=E6=95=B0=E4=B8=8A?= =?UTF-8?q?=E9=99=90=E3=81=AE=E6=9C=89=E5=8A=B9=E7=AF=84=E5=9B=B2=E3=82=92?= =?UTF-8?q?README=E3=81=AB=E6=98=8E=E8=A8=98=E3=81=97=E3=81=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c5c1b72..2c74433 100644 --- a/README.md +++ b/README.md @@ -2668,8 +2668,8 @@ annotation_id = client.create_annotation( 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. It only applies to segmentation classes and defaults to `1`, which disallows disjoint -regions. Set `None` to allow any number of regions. +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( @@ -2844,7 +2844,8 @@ annotation_id = client.update_annotation( Update the maximum number of regions of a segmentation annotation. -`max_area_count` is left unchanged when omitted. Set `None` to allow any number of regions. +`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( From b098470a481a382a9e0841d76a32aaf37ddd402e Mon Sep 17 00:00:00 2001 From: soymd Date: Wed, 19 Aug 2026 16:02:43 +0900 Subject: [PATCH 04/11] =?UTF-8?q?=E3=82=A2=E3=83=8E=E3=83=86=E3=83=BC?= =?UTF-8?q?=E3=82=B7=E3=83=A7=E3=83=B3=E3=82=AF=E3=83=A9=E3=82=B9=E4=BD=9C?= =?UTF-8?q?=E6=88=90=E3=81=A7max=5Farea=5Fcount=E6=9C=AA=E6=8C=87=E5=AE=9A?= =?UTF-8?q?=E6=99=82=E3=81=AF=E3=83=AA=E3=82=AF=E3=82=A8=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=81=AB=E5=90=AB=E3=82=81=E3=81=AA=E3=81=84=E3=82=88=E3=81=86?= =?UTF-8?q?=E3=81=AB=E3=81=97=E3=81=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastlabel/__init__.py | 10 ++++++---- tests/test_annotation.py | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/fastlabel/__init__.py b/fastlabel/__init__.py index fc59e01..9b343f3 100644 --- a/fastlabel/__init__.py +++ b/fastlabel/__init__.py @@ -4311,7 +4311,7 @@ def create_annotation( color: str = None, order: int = None, attributes: list = [], - max_area_count: Optional[int] = 1, + max_area_count: Union[int, None, _Unset] = _UNSET, ) -> str: """ Create an annotation. @@ -4325,8 +4325,9 @@ def create_annotation( 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 defaults to 1, which - disallows disjoint regions. Set None to allow any number of regions. + 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 = { @@ -4334,7 +4335,6 @@ def create_annotation( "type": type, "value": value, "title": title, - "maxAreaCount": max_area_count, } if color: payload["color"] = color @@ -4342,6 +4342,8 @@ def create_annotation( payload["order"] = order if attributes: payload["attributes"] = attributes + if not isinstance(max_area_count, _Unset): + payload["maxAreaCount"] = max_area_count return self.api.post_request(endpoint, payload=payload) def create_classification_annotation(self, project: str, attributes: list) -> str: diff --git a/tests/test_annotation.py b/tests/test_annotation.py index 9dda849..950220c 100644 --- a/tests/test_annotation.py +++ b/tests/test_annotation.py @@ -31,20 +31,20 @@ def fake(endpoint, *args, **kwargs): # --- create_annotation ----------------------------------------------------- -def test_create_annotation_defaults_max_area_count_to_one(monkeypatch, client): +def test_create_annotation_omits_max_area_count_by_default(monkeypatch, client): calls = _capture(monkeypatch, 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", - "maxAreaCount": 1, } From d1efbf832fd701bf5e3386bec62781ac02dc0be1 Mon Sep 17 00:00:00 2001 From: soymd Date: Wed, 19 Aug 2026 16:03:03 +0900 Subject: [PATCH 05/11] =?UTF-8?q?=E3=82=BB=E3=83=B3=E3=83=81=E3=83=8D?= =?UTF-8?q?=E3=83=AB=E3=81=AE=E5=88=A4=E5=AE=9A=E3=82=92isinstance?= =?UTF-8?q?=E3=81=8B=E3=82=89=E5=90=8C=E4=B8=80=E6=80=A7=E6=AF=94=E8=BC=83?= =?UTF-8?q?=E3=81=AB=E5=A4=89=E3=81=88=E3=81=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastlabel/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fastlabel/__init__.py b/fastlabel/__init__.py index 9b343f3..9b6946c 100644 --- a/fastlabel/__init__.py +++ b/fastlabel/__init__.py @@ -4342,7 +4342,7 @@ def create_annotation( payload["order"] = order if attributes: payload["attributes"] = attributes - if not isinstance(max_area_count, _Unset): + if max_area_count is not _UNSET: payload["maxAreaCount"] = max_area_count return self.api.post_request(endpoint, payload=payload) @@ -4392,7 +4392,7 @@ def update_annotation( payload["order"] = order if attributes: payload["attributes"] = attributes - if not isinstance(max_area_count, _Unset): + if max_area_count is not _UNSET: payload["maxAreaCount"] = max_area_count return self.api.put_request(endpoint, payload=payload) From 1aa65b69b702bab9ac4212e07c7bfc58ddf6a76c Mon Sep 17 00:00:00 2001 From: soymd Date: Wed, 19 Aug 2026 16:05:14 +0900 Subject: [PATCH 06/11] =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E3=81=AEclien?= =?UTF-8?q?t=20fixture=E3=81=A8=E3=83=AA=E3=82=AF=E3=82=A8=E3=82=B9?= =?UTF-8?q?=E3=83=88=E8=A8=98=E9=8C=B2=E3=83=98=E3=83=AB=E3=83=91=E3=83=BC?= =?UTF-8?q?=E3=82=92conftest=E3=81=B8=E7=A7=BB=E3=81=97=E3=81=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/conftest.py | 25 +++++++++++++ tests/test_annotation.py | 47 +++++++----------------- tests/test_workspace_user.py | 69 ++++++++++++++---------------------- 3 files changed, 63 insertions(+), 78 deletions(-) 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 index 950220c..1396b36 100644 --- a/tests/test_annotation.py +++ b/tests/test_annotation.py @@ -5,34 +5,11 @@ (client.api.*_request) is stubbed so no real request is made. """ -import pytest - -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 - - # --- create_annotation ----------------------------------------------------- -def test_create_annotation_omits_max_area_count_by_default(monkeypatch, client): - calls = _capture(monkeypatch, client, "post_request", return_value="anno-id") +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" @@ -48,8 +25,8 @@ def test_create_annotation_omits_max_area_count_by_default(monkeypatch, client): } -def test_create_annotation_with_max_area_count(monkeypatch, client): - calls = _capture(monkeypatch, client, "post_request", return_value="anno-id") +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", @@ -62,8 +39,8 @@ def test_create_annotation_with_max_area_count(monkeypatch, client): assert calls[0]["kwargs"]["payload"]["maxAreaCount"] == 10 -def test_create_annotation_without_max_area_count_limit(monkeypatch, client): - calls = _capture(monkeypatch, client, "post_request", return_value="anno-id") +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", @@ -80,8 +57,8 @@ def test_create_annotation_without_max_area_count_limit(monkeypatch, client): # --- update_annotation ----------------------------------------------------- -def test_update_annotation_omits_max_area_count_by_default(monkeypatch, client): - calls = _capture(monkeypatch, client, "put_request", return_value="anno-id") +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") @@ -89,16 +66,16 @@ def test_update_annotation_omits_max_area_count_by_default(monkeypatch, client): assert calls[0]["kwargs"]["payload"] == {"title": "Cat"} -def test_update_annotation_with_max_area_count(monkeypatch, client): - calls = _capture(monkeypatch, client, "put_request", return_value="anno-id") +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(monkeypatch, client): - calls = _capture(monkeypatch, client, "put_request", return_value="anno-id") +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) 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( From 961462de6b2165dd64300fd17d77bb3c1511cf7c Mon Sep 17 00:00:00 2001 From: soymd Date: Wed, 19 Aug 2026 17:07:52 +0900 Subject: [PATCH 07/11] =?UTF-8?q?=E3=82=BB=E3=83=B3=E3=83=81=E3=83=8D?= =?UTF-8?q?=E3=83=AB=E3=81=AE=E5=9E=8B=E6=B3=A8=E9=87=88=E3=82=92=E8=AA=BF?= =?UTF-8?q?=E6=95=B4=E3=81=97=E3=81=A6=E5=85=AC=E9=96=8B=E3=82=B7=E3=82=B0?= =?UTF-8?q?=E3=83=8D=E3=83=81=E3=83=A3=E3=81=8B=E3=82=89=E5=86=85=E9=83=A8?= =?UTF-8?q?=E3=82=AF=E3=83=A9=E3=82=B9=E3=82=92=E9=9A=A0=E3=81=97=E3=81=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastlabel/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/fastlabel/__init__.py b/fastlabel/__init__.py index 9b6946c..e7b84ba 100644 --- a/fastlabel/__init__.py +++ b/fastlabel/__init__.py @@ -50,7 +50,9 @@ def __repr__(self) -> str: return "UNSET" -_UNSET = _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: @@ -4311,7 +4313,7 @@ def create_annotation( color: str = None, order: int = None, attributes: list = [], - max_area_count: Union[int, None, _Unset] = _UNSET, + max_area_count: Optional[int] = _UNSET, ) -> str: """ Create an annotation. @@ -4365,7 +4367,7 @@ def update_annotation( color: str = None, order: int = None, attributes: list = [], - max_area_count: Union[int, None, _Unset] = _UNSET, + max_area_count: Optional[int] = _UNSET, ) -> str: """ Update an annotation. From f6a5497740a06d09a62b23c6cad90409abc0fda5 Mon Sep 17 00:00:00 2001 From: soymd Date: Fri, 21 Aug 2026 09:45:15 +0900 Subject: [PATCH 08/11] =?UTF-8?q?=E6=9C=AA=E6=8C=87=E5=AE=9A=E3=83=9E?= =?UTF-8?q?=E3=83=BC=E3=82=AB=E3=83=BC=E3=82=92=E3=82=B7=E3=83=B3=E3=82=B0?= =?UTF-8?q?=E3=83=AB=E3=83=88=E3=83=B3=E3=81=AB=E3=81=97=E3=81=A6=E3=82=B3?= =?UTF-8?q?=E3=83=94=E3=83=BC=E3=82=84=E3=82=B7=E3=83=AA=E3=82=A2=E3=83=A9?= =?UTF-8?q?=E3=82=A4=E3=82=BA=E3=82=92=E8=B7=A8=E3=81=84=E3=81=A7=E3=82=82?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E6=80=A7=E3=81=8C=E4=BF=9D=E3=81=9F=E3=82=8C?= =?UTF-8?q?=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB=E3=81=97=E3=81=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SDKとして公開する都合上、呼び出し側でキーワード引数がdeepcopyやpickleを 経由することがある。素のインスタンスだと同一性比較が壊れ、マーカー自身が リクエストに載ってしまうため。 --- fastlabel/__init__.py | 11 +++++++++++ tests/test_annotation.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/fastlabel/__init__.py b/fastlabel/__init__.py index e7b84ba..17ee82f 100644 --- a/fastlabel/__init__.py +++ b/fastlabel/__init__.py @@ -44,8 +44,19 @@ class _Unset: Needed where None is a meaningful value that has to be sent to the API, and therefore cannot double as "leave this field untouched". + + 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" diff --git a/tests/test_annotation.py b/tests/test_annotation.py index 1396b36..492c18b 100644 --- a/tests/test_annotation.py +++ b/tests/test_annotation.py @@ -5,6 +5,13 @@ (client.api.*_request) is stubbed so no real request is made. """ +import copy +import pickle + +import pytest + +import fastlabel + # --- create_annotation ----------------------------------------------------- @@ -81,3 +88,32 @@ def test_update_annotation_without_max_area_count_limit(client, capture_request) # 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"] From a990f6b280851091823326990953b0f9ea8a7f37 Mon Sep 17 00:00:00 2001 From: soymd Date: Fri, 21 Aug 2026 09:46:12 +0900 Subject: [PATCH 09/11] =?UTF-8?q?=E6=9C=AA=E6=8C=87=E5=AE=9A=E3=83=9E?= =?UTF-8?q?=E3=83=BC=E3=82=AB=E3=83=BC=E3=82=92=E4=BD=BF=E3=81=86=E7=90=86?= =?UTF-8?q?=E7=94=B1=E3=82=92=E5=91=BC=E3=81=B3=E5=87=BA=E3=81=97=E7=AE=87?= =?UTF-8?q?=E6=89=80=E3=81=A8=E3=83=9E=E3=83=BC=E3=82=AB=E3=83=BC=E5=AE=9A?= =?UTF-8?q?=E7=BE=A9=E3=81=AB=E3=82=B3=E3=83=A1=E3=83=B3=E3=83=88=E3=81=A7?= =?UTF-8?q?=E6=9B=B8=E3=81=84=E3=81=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 省略時はNoneという二値の慣習に対して、この項目はnull自体がAPIの受け付ける 値になるため三値になる。慣習との違いが読み手に伝わらずレビューで疑問が 出たため、マーカーの定義側と使用箇所の双方に意図を残す。 --- fastlabel/__init__.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/fastlabel/__init__.py b/fastlabel/__init__.py index 17ee82f..9097c34 100644 --- a/fastlabel/__init__.py +++ b/fastlabel/__init__.py @@ -42,8 +42,12 @@ class _Unset: """Marker for arguments the caller did not pass. - Needed where None is a meaningful value that has to be sent to the API, - and therefore cannot double as "leave this field untouched". + Most optional arguments here have two states, and None covers the second + one: the field is either sent or left out. A few fields have three, because + null is one of the values the API acts on -- left out, sent as null, sent + as a value. For those, None is taken up by the null that has to reach the + API and cannot also mean "the caller said nothing", so this marker carries + that state instead and the argument is read by identity rather than truth. 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 @@ -4355,6 +4359,9 @@ def create_annotation( payload["order"] = order if attributes: payload["attributes"] = attributes + # Three states, so the argument is read by identity: left out keeps the + # API's own default, None removes the limit, an int sets it. None is a + # value the API acts on and cannot also mean "not passed". if max_area_count is not _UNSET: payload["maxAreaCount"] = max_area_count return self.api.post_request(endpoint, payload=payload) @@ -4405,6 +4412,9 @@ def update_annotation( payload["order"] = order if attributes: payload["attributes"] = attributes + # Three states, so the argument is read by identity: left out keeps the + # stored value, None removes the limit, an int sets it. None is a value + # the API acts on and cannot also mean "not passed". if max_area_count is not _UNSET: payload["maxAreaCount"] = max_area_count return self.api.put_request(endpoint, payload=payload) From 8b77875272e04775ef4c7de772fb1d3f2cb29d4e Mon Sep 17 00:00:00 2001 From: soymd Date: Fri, 21 Aug 2026 11:24:01 +0900 Subject: [PATCH 10/11] =?UTF-8?q?=E3=82=A2=E3=83=8E=E3=83=86=E3=83=BC?= =?UTF-8?q?=E3=82=B7=E3=83=A7=E3=83=B3=E3=82=AF=E3=83=A9=E3=82=B9=E3=81=AE?= =?UTF-8?q?=E9=A0=98=E5=9F=9F=E6=95=B0=E4=B8=8A=E9=99=90=20=E6=9C=AA?= =?UTF-8?q?=E6=8C=87=E5=AE=9A=E3=83=9E=E3=83=BC=E3=82=AB=E3=83=BC=E3=81=AE?= =?UTF-8?q?=E6=84=8F=E5=9B=B3=E3=82=92=E6=97=A5=E6=9C=AC=E8=AA=9E=E3=81=A7?= =?UTF-8?q?=E5=85=B7=E4=BD=93=E7=9A=84=E3=81=AB=E6=9B=B8=E3=81=8D=E7=9B=B4?= =?UTF-8?q?=E3=81=97=E3=81=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit maxAreaCount は API 側で無制限を null で表すため None が「未指定」を兼ねられず 三値になるという理由を、対象フィールド名と API 仕様を名指しする形で明記した。 従来の英語かつ一般化した説明では、レビューで意図が確認できなかったため。 https://github.com/fastlabel/fastlabel-python-sdk/pull/288#discussion_r3826774760 --- fastlabel/__init__.py | 41 +++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/fastlabel/__init__.py b/fastlabel/__init__.py index 9097c34..36074db 100644 --- a/fastlabel/__init__.py +++ b/fastlabel/__init__.py @@ -40,18 +40,17 @@ 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. A few fields have three, because - null is one of the values the API acts on -- left out, sent as null, sent - as a value. For those, None is taken up by the null that has to reach the - API and cannot also mean "the caller said nothing", so this marker carries - that state instead and the argument is read by identity rather than truth. - - 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. + """呼び出し側が引数を渡さなかったことを表すマーカー。 + + このモジュールの任意引数は大半が「送る / 送らない」の二値で、後者を None + が兼ねられる。一方、null 自体が API へ送る意味のある値になる引数 (領域数 + 上限 maxAreaCount は 0 ではなく null が「無制限」を表す) は「省略 / null / + 値」の三値になる。この場合 None は API へ届ける null の側に取られるため + 「呼び出し側が何も言わなかった」を表せず、その状態をこのマーカーが担う。 + 引数は真偽ではなく同一性 (``is``) で判定する。 + + インスタンスは 1 つだけ共有する。呼び出し側のコードで copy やシリアライズ + を経ても、このマーカーを読む同一性比較が壊れないようにするため。 """ _instance: Optional["_Unset"] = None @@ -65,8 +64,8 @@ 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. +# 既定値としてこのマーカーを使う関数の公開シグネチャに内部クラスが露出しない +# よう Any で注釈する。呼び出し側が実際に渡すのは int か None のみ。 _UNSET: Any = _Unset() @@ -4359,9 +4358,10 @@ def create_annotation( payload["order"] = order if attributes: payload["attributes"] = attributes - # Three states, so the argument is read by identity: left out keeps the - # API's own default, None removes the limit, an int sets it. None is a - # value the API acts on and cannot also mean "not passed". + # maxAreaCount は API 側で「無制限」を null で表すため、None を渡すこと + # 自体が意味のある操作になる。他の任意引数のように None を「未指定」に + # 流用できず、省略 (API 既定値のまま) / None (無制限) / int (上限値) の + # 三値になるので、_UNSET との同一性比較で省略かどうかを見分ける。 if max_area_count is not _UNSET: payload["maxAreaCount"] = max_area_count return self.api.post_request(endpoint, payload=payload) @@ -4412,9 +4412,10 @@ def update_annotation( payload["order"] = order if attributes: payload["attributes"] = attributes - # Three states, so the argument is read by identity: left out keeps the - # stored value, None removes the limit, an int sets it. None is a value - # the API acts on and cannot also mean "not passed". + # maxAreaCount は API 側で「無制限」を null で表すため、None を渡すこと + # 自体が意味のある操作になる。他の任意引数のように None を「未指定」に + # 流用できず、省略 (保存済みの値のまま) / None (無制限) / int (上限値) + # の三値になるので、_UNSET との同一性比較で省略かどうかを見分ける。 if max_area_count is not _UNSET: payload["maxAreaCount"] = max_area_count return self.api.put_request(endpoint, payload=payload) From fb6b53d384abb8b948bb469137582893a93141d3 Mon Sep 17 00:00:00 2001 From: soymd Date: Fri, 21 Aug 2026 13:45:25 +0900 Subject: [PATCH 11/11] =?UTF-8?q?=E3=82=A2=E3=83=8E=E3=83=86=E3=83=BC?= =?UTF-8?q?=E3=82=B7=E3=83=A7=E3=83=B3=E3=82=AF=E3=83=A9=E3=82=B9=E3=81=AE?= =?UTF-8?q?=E9=A0=98=E5=9F=9F=E6=95=B0=E4=B8=8A=E9=99=90=20=E6=9C=AA?= =?UTF-8?q?=E6=8C=87=E5=AE=9A=E3=83=9E=E3=83=BC=E3=82=AB=E3=83=BC=E3=81=AE?= =?UTF-8?q?=E8=AA=AC=E6=98=8E=E3=82=92=E8=8B=B1=E8=AA=9E=E3=81=AB=E6=88=BB?= =?UTF-8?q?=E3=81=97=E3=81=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SDK 全体のコメントが英語で統一されているため、日本語化した分を戻した。 maxAreaCount を名指しし null が無制限を表すという API 仕様を書く具体度は そのまま英語で維持した。 https://github.com/fastlabel/fastlabel-python-sdk/pull/288#discussion_r3827317862 --- fastlabel/__init__.py | 46 +++++++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/fastlabel/__init__.py b/fastlabel/__init__.py index 36074db..11e40dd 100644 --- a/fastlabel/__init__.py +++ b/fastlabel/__init__.py @@ -40,17 +40,19 @@ class _Unset: - """呼び出し側が引数を渡さなかったことを表すマーカー。 - - このモジュールの任意引数は大半が「送る / 送らない」の二値で、後者を None - が兼ねられる。一方、null 自体が API へ送る意味のある値になる引数 (領域数 - 上限 maxAreaCount は 0 ではなく null が「無制限」を表す) は「省略 / null / - 値」の三値になる。この場合 None は API へ届ける null の側に取られるため - 「呼び出し側が何も言わなかった」を表せず、その状態をこのマーカーが担う。 - 引数は真偽ではなく同一性 (``is``) で判定する。 - - インスタンスは 1 つだけ共有する。呼び出し側のコードで copy やシリアライズ - を経ても、このマーカーを読む同一性比較が壊れないようにするため。 + """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 @@ -64,8 +66,8 @@ def __repr__(self) -> str: return "UNSET" -# 既定値としてこのマーカーを使う関数の公開シグネチャに内部クラスが露出しない -# よう Any で注釈する。呼び出し側が実際に渡すのは int か None のみ。 +# 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() @@ -4358,10 +4360,11 @@ def create_annotation( payload["order"] = order if attributes: payload["attributes"] = attributes - # maxAreaCount は API 側で「無制限」を null で表すため、None を渡すこと - # 自体が意味のある操作になる。他の任意引数のように None を「未指定」に - # 流用できず、省略 (API 既定値のまま) / None (無制限) / int (上限値) の - # 三値になるので、_UNSET との同一性比較で省略かどうかを見分ける。 + # 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) @@ -4412,10 +4415,11 @@ def update_annotation( payload["order"] = order if attributes: payload["attributes"] = attributes - # maxAreaCount は API 側で「無制限」を null で表すため、None を渡すこと - # 自体が意味のある操作になる。他の任意引数のように None を「未指定」に - # 流用できず、省略 (保存済みの値のまま) / None (無制限) / int (上限値) - # の三値になるので、_UNSET との同一性比較で省略かどうかを見分ける。 + # 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)