diff --git a/annofabapi/util/annotation_specs.py b/annofabapi/util/annotation_specs.py index 0b826c82..ee8a1024 100644 --- a/annofabapi/util/annotation_specs.py +++ b/annofabapi/util/annotation_specs.py @@ -1,15 +1,14 @@ -from __future__ import annotations - from typing import Any, Literal, Optional, Union import more_itertools +from more_itertools import first_true from annofabapi.models import Lang def get_english_message(internationalization_message: dict[str, Any]) -> str: """ - `InternalizationMessage`クラスの値から、英語メッセージを取得します。 + `InternationalizationMessage`クラスの値から、英語メッセージを取得します。 英語メッセージが見つからない場合は ``ValueError`` をスローします。 Notes: @@ -38,9 +37,9 @@ def get_english_message(internationalization_message: dict[str, Any]) -> str: """ -def get_message_with_lang(internationalization_message: dict[str, Any], lang: Union[Lang, STR_LANG]) -> Optional[str]: # noqa: UP007 +def get_message_with_lang(internationalization_message: dict[str, Any], lang: Union[Lang, STR_LANG]) -> Optional[str]: """ - `InternalizationMessage`クラスの値から、指定した ``lang`` に対応するメッセージを取得します。 + `InternationalizationMessage`クラスの値から、指定した ``lang`` に対応するメッセージを取得します。 Args: internationalization_message: 多言語化されたメッセージ。キー ``messages`` が存在している必要があります。 @@ -60,3 +59,93 @@ def get_message_with_lang(internationalization_message: dict[str, Any], lang: Un if result is not None: return result["message"] return None + + +def get_choice(choices: list[dict[str, Any]], *, choice_id: Optional[str] = None, choice_name: Optional[str] = None) -> dict[str, Any]: + """ + 選択肢情報を取得します。 + + Args: + choice_id: 選択肢ID + choice_name: 選択肢名(英語) + """ + if choice_id is not None: + result = first_true(choices, pred=lambda e: e["choice_id"] == choice_id) + elif choice_name is not None: + result = first_true(choices, pred=lambda e: get_english_message(e["name"]) == choice_name) + else: + raise ValueError("choice_idまたはchoice_nameのいずれかを指定してください。") + if result is None: + raise ValueError(f"選択肢情報が見つかりませんでした。 :: choice_id='{choice_id}', choice_name='{choice_name}'") + return result + + +def get_attribute(additionals: list[dict[str, Any]], *, attribute_id: Optional[str] = None, attribute_name: Optional[str] = None) -> dict[str, Any]: + """ + 属性情報を取得します。 + + Args: + attribute_id: 属性ID + attribute_name: 属性名(英語) + """ + if attribute_id is not None: + result = first_true(additionals, pred=lambda e: e["additional_data_definition_id"] == attribute_id) + elif attribute_name is not None: + result = first_true(additionals, pred=lambda e: get_english_message(e["name"]) == attribute_name) + else: + raise ValueError("attribute_idまたはattribute_nameのいずれかを指定してください。") + if result is None: + raise ValueError(f"属性情報が見つかりませんでした。 :: attribute_id='{attribute_id}', attribute_name='{attribute_name}'") + return result + + +def get_label(labels: list[dict[str, Any]], *, label_id: Optional[str] = None, label_name: Optional[str] = None) -> dict[str, Any]: + """ + ラベル情報を取得します。 + + Args: + label_id: ラベルID + label_name: ラベル名(英語) + """ + if label_id is not None: + result = first_true(labels, pred=lambda e: e["label_id"] == label_id) + elif label_name is not None: + result = first_true(labels, pred=lambda e: get_english_message(e["label_name"]) == label_name) + else: + raise ValueError("label_idまたはlabel_nameのいずれかを指定してください。") + if result is None: + raise ValueError(f"ラベル情報が見つかりませんでした。 :: label_id='{label_id}', label_name='{label_name}'") + return result + + +class AnnotationSpecsAccessor: + """ + アノテーション仕様の情報にアクセスするためのクラス。 + + Args: + annotation_specs: アノテーション仕様(v3)の情報 + """ + + def __init__(self, annotation_specs: dict[str, Any]) -> None: + self.labels = annotation_specs["labels"] + self.additionals = annotation_specs["additionals"] + + def get_attribute(self, *, attribute_id: Optional[str] = None, attribute_name: Optional[str] = None) -> dict[str, Any]: + """ + 属性情報を取得します。 + + Args: + attribute_id: 属性ID + attribute_name: 属性名(英語) + """ + return get_attribute(self.additionals, attribute_id=attribute_id, attribute_name=attribute_name) + + def get_label(self, *, label_id: Optional[str] = None, label_name: Optional[str] = None) -> dict[str, Any]: + """ + ラベル情報を取得します。 + + Args: + label_id: ラベルID + label_name: ラベル名(英語) + """ + return get_label(self.labels, label_id=label_id, label_name=label_name) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py new file mode 100644 index 00000000..4d01d1c1 --- /dev/null +++ b/annofabapi/util/attribute_restrictions.py @@ -0,0 +1,301 @@ +""" +属性の制約に関するモジュール。 + +以下のサンプルコードのように属性名で制約情報を出力できます。 + +Examples: + .. code-block:: python + + >>> from annofabapi.util.annotation_specs import AnnotationSpecsAccessor + >>> from annofabapi.util.attribute_restrictions import Checkbox, Selection, StringTextBox + >>> service = annofabapi.build() + >>> annotation_specs, _ = service.api.get_annotation_specs("prj1", query_params={"v": "3"}) + >>> accessor = AnnotationSpecsAccessor(annotation_specs) + + # 「'occluded'チェックボックスがONならば、'note'テキストボックスは空ではない」という制約 + >>> premise_restriction = Checkbox(accessor, attribute_name="occluded").checked() + >>> conclusion_restriction = StringTextBox(accessor, attribute_name="note").is_not_empty() + >>> restriction = premise_restriction.imply(conclusion_restriction) + >>> restriction.to_dict() + { + "additional_data_definition_id": "9b05648d-1e16-4ea2-ab79-48907f5eed00", + "condition": { + "_type": "Imply", + "premise": { + "additional_data_definition_id": "2517f635-2269-4142-8ef4-16312b4cc9f7", + "condition": {"_type": "Equals", "value": "true"}, + }, + "condition": {"_type": "NotEquals", "value": ""}, + }, + } + + # 「'occluded'チェックボックスがONならば、'car_kind'セレクトボックス(またはラジオボタン)は選択肢'general_car'を選択しない」という制約 + >>> premise_restriction = Checkbox(accessor, attribute_name="occluded").checked() + >>> conclusion_restriction = Selection(accessor, attribute_name="car_kind").not_has_choice(choice_name="general_car") + >>> restriction = premise_restriction.imply(conclusion_restriction) + >>> restriction.to_dict() + { + "additional_data_definition_id": "cbb0155f-1631-48e1-8fc3-43c5f254b6f2", + "condition": { + "_type": "Imply", + "premise": { + "additional_data_definition_id": "2517f635-2269-4142-8ef4-16312b4cc9f7", + "condition": {"_type": "Equals", "value": "true"}, + }, + "condition": {"_type": "Equals", "value": "7512ee39-8073-4e24-9b8c-93d99b76b7d2"}, + }, + } +""" + +from abc import ABC, abstractmethod +from collections.abc import Collection +from typing import Any, Optional + +from annofabapi.util.annotation_specs import AnnotationSpecsAccessor, get_choice + + +class Restriction(ABC): + """ + 属性の制約を表すクラス。 + """ + + def __init__(self, attribute_id: str) -> None: + self.attribute_id = attribute_id + + def to_dict(self) -> dict[str, Any]: + """ + アノテーション仕様の`restrictions`に格納できるdictを出力します。 + """ + return {"additional_data_definition_id": self.attribute_id, "condition": self._to_dict_only_condition()} + + @abstractmethod + def _to_dict_only_condition(self) -> dict[str, Any]: + """ + 制約の条件部分のみdictで出力します。 + """ + + def imply(self, conclusion_restriction: "Restriction") -> "Restriction": + return Imply(premise_restriction=self, conclusion_restriction=conclusion_restriction) + + +class Imply(Restriction): + """ + 「AならB」という制約を表すクラス + + Args: + premise_restriction: 前提となる制約 + conclusion_restriction: 最終的に満たしたい制約 + """ + + def __init__(self, premise_restriction: Restriction, conclusion_restriction: Restriction) -> None: + super().__init__(conclusion_restriction.attribute_id) + self.premise_restriction = premise_restriction + self.conclusion_restriction = conclusion_restriction + + def imply(self, conclusion_restriction: "Restriction") -> "Restriction": + raise NotImplementedError("`imply`メソッドの戻り値に対して`imply`メソッドを実行できません。") + + def _to_dict_only_condition(self) -> dict[str, Any]: + return {"_type": "Imply", "premise": self.premise_restriction.to_dict(), "condition": self.conclusion_restriction._to_dict_only_condition()} + + +class CanInput(Restriction): + def __init__(self, attribute_id: str, enable: bool) -> None: + super().__init__(attribute_id) + self.enable = enable + + def _to_dict_only_condition(self) -> dict[str, Any]: + return {"_type": "CanInput", "enable": self.enable} + + +class Equals(Restriction): + def __init__(self, attribute_id: str, value: str) -> None: + super().__init__(attribute_id) + self.value = value + + def _to_dict_only_condition(self) -> dict[str, Any]: + return {"_type": "Equals", "value": self.value} + + +class NotEquals(Restriction): + def __init__(self, attribute_id: str, value: str) -> None: + super().__init__(attribute_id) + self.value = value + + def _to_dict_only_condition(self) -> dict[str, Any]: + return {"_type": "NotEquals", "value": self.value} + + +class Matches(Restriction): + def __init__(self, attribute_id: str, value: str) -> None: + super().__init__(attribute_id) + self.value = value + + def _to_dict_only_condition(self) -> dict[str, Any]: + return {"_type": "Matches", "value": self.value} + + +class NotMatches(Restriction): + def __init__(self, attribute_id: str, value: str) -> None: + super().__init__(attribute_id) + self.value = value + + def _to_dict_only_condition(self) -> dict[str, Any]: + return {"_type": "NotMatches", "value": self.value} + + +class HasLabel(Restriction): + def __init__(self, attribute_id: str, label_ids: Collection[str]) -> None: + super().__init__(attribute_id) + self.label_ids = label_ids + + def _to_dict_only_condition(self) -> dict[str, Any]: + return {"_type": "HasLabel", "labels": list(self.label_ids)} + + +class EmptyCheckMixin: + """属性が空かどうかを判定するメソッドを提供するMix-inクラス""" + + attribute_id: str + + def is_empty(self) -> Restriction: + """属性値が空であるという制約""" + return Equals(self.attribute_id, value="") + + def is_not_empty(self) -> Restriction: + """属性値が空でないという制約""" + return NotEquals(self.attribute_id, value="") + + +class Attribute(ABC): + def __init__(self, accessor: AnnotationSpecsAccessor, *, attribute_id: Optional[str] = None, attribute_name: Optional[str] = None) -> None: + self.accessor = accessor + self.attribute = self.accessor.get_attribute(attribute_id=attribute_id, attribute_name=attribute_name) + self.attribute_id = self.attribute["additional_data_definition_id"] + if self._is_valid_attribute_type() is False: + raise ValueError(f"属性の種類が'{self.attribute['type']}'である属性は、クラス'{self.__class__.__name__}'では扱えません。") + + def disabled(self) -> Restriction: + """属性値を入力できないという制約""" + return CanInput(self.attribute_id, enable=False) + + @abstractmethod + def _is_valid_attribute_type(self) -> bool: + pass + + +class Checkbox(Attribute): + """チェックボックスの属性""" + + def checked(self) -> Restriction: + """チェックされているという制約""" + return Equals(self.attribute_id, "true") + + def unchecked(self) -> Restriction: + """チェックされていないという制約""" + return NotEquals(self.attribute_id, "true") + + def _is_valid_attribute_type(self) -> bool: + return self.attribute["type"] == "flag" + + +class StringTextBox(Attribute, EmptyCheckMixin): + """文字列用のテキストボックス(自由記述)の属性""" + + def _is_valid_attribute_type(self) -> bool: + return self.attribute["type"] in {"text", "comment"} + + def equals(self, value: str) -> Restriction: + """引数`value`に渡された文字列に一致するという制約""" + return Equals(self.attribute_id, value) + + def not_equals(self, value: str) -> Restriction: + """引数`value`に渡された文字列に一致しないという制約""" + return NotEquals(self.attribute_id, value) + + def matches(self, value: str) -> Restriction: + """引数`value`に渡された正規表現に一致するという制約""" + return Matches(self.attribute_id, value) + + def not_matches(self, value: str) -> Restriction: + """引数`value`に渡された正規表現に一致しないという制約""" + return NotMatches(self.attribute_id, value) + + +class IntegerTextBox(Attribute, EmptyCheckMixin): + """整数用のテキストボックスの属性""" + + def _is_valid_attribute_type(self) -> bool: + return self.attribute["type"] == "integer" + + def equals(self, value: int) -> Restriction: + """引数`value`に渡された整数に一致するという制約""" + return Equals(self.attribute_id, str(value)) + + def not_equals(self, value: int) -> Restriction: + """引数`value`に渡された整数に一致しないという制約""" + return NotEquals(self.attribute_id, str(value)) + + +class AnnotationLink(Attribute, EmptyCheckMixin): + """アノテーションリンク属性""" + + def _is_valid_attribute_type(self) -> bool: + return self.attribute["type"] == "link" + + def has_label(self, label_ids: Optional[Collection[str]] = None, label_names: Optional[Collection[str]] = None) -> Restriction: + """リンク先のアノテーションが、引数`label_ids`または`label_names`に一致するラベルであるという制約""" + if label_ids is not None: + labels = [self.accessor.get_label(label_id=label_id) for label_id in label_ids] + elif label_names is not None: + labels = [self.accessor.get_label(label_name=label_name) for label_name in label_names] + else: + raise ValueError("label_idsまたはlabel_namesのいずれかを指定してください。") + + return HasLabel(self.attribute_id, label_ids=[label["label_id"] for label in labels]) + + +class TrackingId(Attribute, EmptyCheckMixin): + """トラッキングID属性""" + + def _is_valid_attribute_type(self) -> bool: + return self.attribute["type"] == "tracking" + + def equals(self, value: str) -> Restriction: + """引数`value`に渡された文字列に一致するという制約""" + return Equals(self.attribute_id, value) + + def not_equals(self, value: str) -> Restriction: + """引数`value`に渡された文字列に一致しないという制約""" + return NotEquals(self.attribute_id, value) + + +class Selection(Attribute, EmptyCheckMixin): + """排他選択の属性(ドロップダウンまたラジオボタン)""" + + def _is_valid_attribute_type(self) -> bool: + return self.attribute["type"] in {"choice", "select"} + + def has_choice(self, *, choice_id: Optional[str] = None, choice_name: Optional[str] = None) -> Restriction: + """引数`choice_id`または`choice_name`に一致する選択肢が選択されているという制約""" + choices = self.attribute["choices"] + choice = get_choice(choices, choice_id=choice_id, choice_name=choice_name) + return Equals(self.attribute_id, choice["choice_id"]) + + def not_has_choice(self, *, choice_id: Optional[str] = None, choice_name: Optional[str] = None) -> Restriction: + """引数`choice_id`または`choice_name`に一致する選択肢が選択されていないという制約""" + choices = self.attribute["choices"] + choice = get_choice(choices, choice_id=choice_id, choice_name=choice_name) + return NotEquals(self.attribute_id, choice["choice_id"]) + + +###### + +# accessor = AnnotationSpecsAccessor() +# s.get_attribute(name=) +# Selection("id1", choices=[]).is_selected("choice1").imply() + +# Selection(accessor, attribute_name="id1").is_selected("choice1").imply() + + +# TrackingId(s.get_attribute_id(name="foo")) diff --git a/docs/api_reference/index.rst b/docs/api_reference/index.rst index b93e31fb..4595c2e3 100644 --- a/docs/api_reference/index.rst +++ b/docs/api_reference/index.rst @@ -8,7 +8,6 @@ API reference api2 wrapper resource - utils parser plugin dataclass @@ -16,5 +15,8 @@ API reference segmentation pydantic_models models + util + utils + credentials diff --git a/docs/api_reference/util.rst b/docs/api_reference/util.rst new file mode 100644 index 00000000..12fc34d8 --- /dev/null +++ b/docs/api_reference/util.rst @@ -0,0 +1,37 @@ +annofabapi.util package +======================= + +Submodules +---------- + +annofabapi.util.annotation\_specs module +---------------------------------------- + +.. automodule:: annofabapi.util.annotation_specs + :members: + :undoc-members: + :show-inheritance: + +annofabapi.util.attribute\_restrictions module +---------------------------------------------- + +.. automodule:: annofabapi.util.attribute_restrictions + :members: + :undoc-members: + :show-inheritance: + +annofabapi.util.type\_util module +--------------------------------- + +.. automodule:: annofabapi.util.type_util + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: annofabapi.util + :members: + :undoc-members: + :show-inheritance: diff --git a/pyproject.toml b/pyproject.toml index a503f310..7dc7711c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,6 +78,7 @@ plugins = ["numpy.typing.mypy_plugin"] exclude = [ 'annofabapi/pydantic_models/.*\.py', ] +show_column_numbers = true [tool.ruff] target-version = "py39" diff --git a/tests/data/util/attribute_restrictions/annotation_specs.json b/tests/data/util/attribute_restrictions/annotation_specs.json new file mode 100644 index 00000000..3572a1fd --- /dev/null +++ b/tests/data/util/attribute_restrictions/annotation_specs.json @@ -0,0 +1,902 @@ +{ + "labels" : [ + { + "label_id" : "9d6cca8d-3f5a-4808-a6c9-0ae18a478176", + "label_name" : { + "messages" : [ + { + "lang" : "en-US", + "message" : "car" + }, + { + "lang" : "ja-JP", + "message" : "自動車" + }, + { + "lang" : "vi-VN", + "message" : "car" + } + ], + "default_lang" : "ja-JP" + }, + "keybind" : [ + { + "code" : "KeyQ", + "shift" : false, + "ctrl" : false, + "alt" : false + } + ], + "annotation_type" : "bounding_box", + "field_values" : { + "minimum_size_2d_with_default_insert_position" : { + "min_warn_rule" : { + "_type" : "And" + }, + "min_width" : 35, + "min_height" : 35, + "position_for_minimum_bounding_box_insertion" : null, + "_type" : "MinimumSize2dWithDefaultInsertPosition" + }, + "margin_of_error_tolerance" : { + "max_pixel" : 3, + "_type" : "MarginOfErrorTolerance" + } + }, + "additional_data_definitions" : [ + "cbb0155f-1631-48e1-8fc3-43c5f254b6f2", + "d349e76d-b59a-44cd-94b4-713a00b2e84d", + "ec27de5d-122c-40e7-89bc-5500e37bae6a", + "69a20a12-ef5f-446f-a03e-0c4ab487ff90", + "2517f635-2269-4142-8ef4-16312b4cc9f7", + "9b05648d-1e16-4ea2-ab79-48907f5eed00", + "e771ac4b-97d1-4af3-ba4b-f0e5b22e8648" + ], + "color" : { + "red" : 251, + "green" : 0, + "blue" : 0 + }, + "metadata" : { + + } + }, + { + "label_id" : "39d05700-7c12-4732-bc35-02d65367cc3e", + "label_name" : { + "messages" : [ + { + "lang" : "en-US", + "message" : "number_plate" + }, + { + "lang" : "ja-JP", + "message" : "ナンバープレート" + }, + { + "lang" : "vi-VN", + "message" : "number_plate" + } + ], + "default_lang" : "ja-JP" + }, + "keybind" : [ + { + "code" : "KeyW", + "shift" : false, + "ctrl" : false, + "alt" : false + } + ], + "annotation_type" : "bounding_box", + "field_values" : { + "minimum_size_2d_with_default_insert_position" : { + "min_warn_rule" : { + "_type" : "And" + }, + "min_width" : 16, + "min_height" : 8, + "position_for_minimum_bounding_box_insertion" : null, + "_type" : "MinimumSize2dWithDefaultInsertPosition" + }, + "margin_of_error_tolerance" : { + "max_pixel" : 3, + "_type" : "MarginOfErrorTolerance" + } + }, + "additional_data_definitions" : [ + "15ba8b9d-4882-40c2-bb31-ed3f68197c2e" + ], + "color" : { + "red" : 0, + "green" : 0, + "blue" : 251 + }, + "metadata" : { + + } + }, + { + "label_id" : "afc8ffef-ce87-463d-bf62-070771465438", + "label_name" : { + "messages" : [ + { + "lang" : "en-US", + "message" : "white_line" + }, + { + "lang" : "ja-JP", + "message" : "白線" + }, + { + "lang" : "vi-VN", + "message" : "white_line" + } + ], + "default_lang" : "ja-JP" + }, + "keybind" : [ + { + "code" : "KeyE", + "shift" : false, + "ctrl" : false, + "alt" : false + } + ], + "annotation_type" : "polyline", + "field_values" : { + "display_line_direction" : { + "has_direction" : false, + "_type" : "DisplayLineDirection" + }, + "minimum_size_2d" : { + "min_warn_rule" : { + "_type" : "And" + }, + "min_width" : 6, + "min_height" : 6, + "_type" : "MinimumSize2d" + }, + "margin_of_error_tolerance" : { + "max_pixel" : 3, + "_type" : "MarginOfErrorTolerance" + } + }, + "additional_data_definitions" : [ + ], + "color" : { + "red" : 225, + "green" : 255, + "blue" : 0 + }, + "metadata" : { + + } + }, + { + "label_id" : "cf4d3a30-4efe-410d-b5e0-c35fd46d080c", + "label_name" : { + "messages" : [ + { + "lang" : "en-US", + "message" : "road" + }, + { + "lang" : "ja-JP", + "message" : "路面" + }, + { + "lang" : "vi-VN", + "message" : "road" + } + ], + "default_lang" : "ja-JP" + }, + "keybind" : [ + { + "code" : "KeyR", + "shift" : false, + "ctrl" : false, + "alt" : false + } + ], + "annotation_type" : "segmentation_v2", + "field_values" : { + "minimum_size_2d" : { + "min_warn_rule" : { + "_type" : "Or" + }, + "min_width" : 6, + "min_height" : 6, + "_type" : "MinimumSize2d" + }, + "margin_of_error_tolerance" : { + "max_pixel" : 3, + "_type" : "MarginOfErrorTolerance" + }, + "annotation_editor_feature" : { + "append" : true, + "erase" : true, + "freehand" : true, + "rectangle_fill" : true, + "polygon_fill" : true, + "fill_near" : true, + "_type" : "AnnotationEditorFeature" + } + }, + "additional_data_definitions" : [ + ], + "color" : { + "red" : 253, + "green" : 88, + "blue" : 248 + }, + "metadata" : { + + } + }, + { + "label_id" : "7391e5f4-38e9-4660-85b9-3d908506634c", + "label_name" : { + "messages" : [ + { + "lang" : "en-US", + "message" : "traffic_sign" + }, + { + "lang" : "ja-JP", + "message" : "交通標識" + }, + { + "lang" : "vi-VN", + "message" : "traffic_sign" + } + ], + "default_lang" : "ja-JP" + }, + "keybind" : [ + { + "code" : "KeyT", + "shift" : false, + "ctrl" : false, + "alt" : false + } + ], + "annotation_type" : "polygon", + "field_values" : { + "minimum_size_2d" : { + "min_warn_rule" : { + "_type" : "Or" + }, + "min_width" : 6, + "min_height" : 6, + "_type" : "MinimumSize2d" + }, + "margin_of_error_tolerance" : { + "max_pixel" : 3, + "_type" : "MarginOfErrorTolerance" + } + }, + "additional_data_definitions" : [ + ], + "color" : { + "red" : 0, + "green" : 255, + "blue" : 0 + }, + "metadata" : { + + } + }, + { + "label_id" : "fcb847a5-5607-4467-a72b-fc11fb5cfbab", + "label_name" : { + "messages" : [ + { + "lang" : "en-US", + "message" : "whole" + }, + { + "lang" : "ja-JP", + "message" : "全体" + }, + { + "lang" : "vi-VN", + "message" : "whole" + } + ], + "default_lang" : "ja-JP" + }, + "keybind" : [ + { + "code" : "KeyY", + "shift" : false, + "ctrl" : false, + "alt" : false + } + ], + "annotation_type" : "classification", + "field_values" : { + + }, + "additional_data_definitions" : [ + "fff3fcc3-093d-41ce-90cf-b4d9b2688b78" + ], + "color" : { + "red" : 241, + "green" : 192, + "blue" : 243 + }, + "metadata" : { + + } + } + ], + "additionals" : [ + { + "additional_data_definition_id" : "15ba8b9d-4882-40c2-bb31-ed3f68197c2e", + "read_only" : false, + "name" : { + "messages" : [ + { + "lang" : "en-US", + "message" : "link_car" + }, + { + "lang" : "ja-JP", + "message" : "リンク_車両" + }, + { + "lang" : "vi-VN", + "message" : "link_car" + } + ], + "default_lang" : "ja-JP" + }, + "keybind" : [ + { + "code" : "Digit4", + "shift" : false, + "ctrl" : false, + "alt" : false + } + ], + "type" : "link", + "default" : "", + "choices" : [ + ], + "metadata" : { + + } + }, + { + "additional_data_definition_id" : "d349e76d-b59a-44cd-94b4-713a00b2e84d", + "read_only" : false, + "name" : { + "messages" : [ + { + "lang" : "en-US", + "message" : "tracking" + }, + { + "lang" : "ja-JP", + "message" : "トラッキング" + }, + { + "lang" : "vi-VN", + "message" : "tracking" + } + ], + "default_lang" : "ja-JP" + }, + "keybind" : [ + { + "code" : "Digit1", + "shift" : false, + "ctrl" : false, + "alt" : false + } + ], + "type" : "tracking", + "default" : "", + "choices" : [ + ], + "metadata" : { + + } + }, + { + "additional_data_definition_id" : "cbb0155f-1631-48e1-8fc3-43c5f254b6f2", + "read_only" : false, + "name" : { + "messages" : [ + { + "lang" : "en-US", + "message" : "car_kind" + }, + { + "lang" : "ja-JP", + "message" : "種別" + }, + { + "lang" : "vi-VN", + "message" : "car_kind" + } + ], + "default_lang" : "ja-JP" + }, + "keybind" : [ + ], + "type" : "choice", + "default" : "7512ee39-8073-4e24-9b8c-93d99b76b7d2", + "choices" : [ + { + "choice_id" : "7512ee39-8073-4e24-9b8c-93d99b76b7d2", + "name" : { + "messages" : [ + { + "lang" : "en-US", + "message" : "general_car" + }, + { + "lang" : "ja-JP", + "message" : "車両一般" + }, + { + "lang" : "vi-VN", + "message" : "general_car" + } + ], + "default_lang" : "ja-JP" + }, + "keybind" : [ + { + "code" : "Digit1", + "shift" : true, + "ctrl" : false, + "alt" : false + } + ] + }, + { + "choice_id" : "c07f9702-4760-4e7c-824d-b87bac356a80", + "name" : { + "messages" : [ + { + "lang" : "en-US", + "message" : "emergency_vehicle" + }, + { + "lang" : "ja-JP", + "message" : "緊急車両" + }, + { + "lang" : "vi-VN", + "message" : "emergency_vehicle" + } + ], + "default_lang" : "ja-JP" + }, + "keybind" : [ + { + "code" : "Digit2", + "shift" : true, + "ctrl" : false, + "alt" : false + } + ] + }, + { + "choice_id" : "75e848f81a-ce06-4669-bd07-4af96306de56", + "name" : { + "messages" : [ + { + "lang" : "en-US", + "message" : "construction_vehicle" + }, + { + "lang" : "ja-JP", + "message" : "重機" + }, + { + "lang" : "vi-VN", + "message" : "construction_vehicle" + } + ], + "default_lang" : "ja-JP" + }, + "keybind" : [ + { + "code" : "Digit3", + "shift" : true, + "ctrl" : false, + "alt" : false + } + ] + } + ], + "metadata" : { + + } + }, + { + "additional_data_definition_id" : "fff3fcc3-093d-41ce-90cf-b4d9b2688b78", + "read_only" : false, + "name" : { + "messages" : [ + { + "lang" : "en-US", + "message" : "weater" + }, + { + "lang" : "ja-JP", + "message" : "天候" + }, + { + "lang" : "vi-VN", + "message" : "weater" + } + ], + "default_lang" : "ja-JP" + }, + "keybind" : [ + ], + "type" : "choice", + "default" : "", + "choices" : [ + { + "choice_id" : "c557a034-1abc-479a-bed3-3a33c006a195", + "name" : { + "messages" : [ + { + "lang" : "en-US", + "message" : "fine" + }, + { + "lang" : "ja-JP", + "message" : "晴れ" + }, + { + "lang" : "vi-VN", + "message" : "fine" + } + ], + "default_lang" : "ja-JP" + }, + "keybind" : [ + { + "code" : "Digit5", + "shift" : false, + "ctrl" : false, + "alt" : false + } + ] + }, + { + "choice_id" : "10744a0f-ceb0-4064-b2ce-f7d2d5714794", + "name" : { + "messages" : [ + { + "lang" : "en-US", + "message" : "cloudy" + }, + { + "lang" : "ja-JP", + "message" : "曇り" + }, + { + "lang" : "vi-VN", + "message" : "cloudy" + } + ], + "default_lang" : "ja-JP" + }, + "keybind" : [ + { + "code" : "Digit6", + "shift" : false, + "ctrl" : false, + "alt" : false + } + ] + }, + { + "choice_id" : "49515f1d-cc5c-41c1-8c5c-a13764ab30d2", + "name" : { + "messages" : [ + { + "lang" : "en-US", + "message" : "rainy" + }, + { + "lang" : "ja-JP", + "message" : "雨" + }, + { + "lang" : "vi-VN", + "message" : "rainy" + } + ], + "default_lang" : "ja-JP" + }, + "keybind" : [ + { + "code" : "Digit7", + "shift" : false, + "ctrl" : false, + "alt" : false + } + ] + }, + { + "choice_id" : "8f04498e-079f-409b-8e7c-703e006bdceb", + "name" : { + "messages" : [ + { + "lang" : "en-US", + "message" : "other" + }, + { + "lang" : "ja-JP", + "message" : "不明" + }, + { + "lang" : "vi-VN", + "message" : "other" + } + ], + "default_lang" : "ja-JP" + }, + "keybind" : [ + { + "code" : "Digit8", + "shift" : false, + "ctrl" : false, + "alt" : false + } + ] + } + ], + "metadata" : { + + } + }, + { + "additional_data_definition_id" : "ec27de5d-122c-40e7-89bc-5500e37bae6a", + "read_only" : false, + "name" : { + "messages" : [ + { + "lang" : "en-US", + "message" : "traffic_lane" + }, + { + "lang" : "ja-JP", + "message" : "車線" + }, + { + "lang" : "vi-VN", + "message" : "traffic_lane" + } + ], + "default_lang" : "ja-JP" + }, + "keybind" : [ + { + "code" : "Digit2", + "shift" : false, + "ctrl" : false, + "alt" : false + } + ], + "type" : "integer", + "default" : "", + "choices" : [ + ], + "metadata" : { + + } + }, + { + "additional_data_definition_id" : "69a20a12-ef5f-446f-a03e-0c4ab487ff90", + "read_only" : false, + "name" : { + "messages" : [ + { + "lang" : "en-US", + "message" : "condition" + }, + { + "lang" : "ja-JP", + "message" : "状態" + }, + { + "lang" : "vi-VN", + "message" : "condition" + } + ], + "default_lang" : "ja-JP" + }, + "keybind" : [ + { + "code" : "Digit3", + "shift" : false, + "ctrl" : false, + "alt" : false + } + ], + "type" : "select", + "default" : "", + "choices" : [ + { + "choice_id" : "stopping", + "name" : { + "messages" : [ + { + "lang" : "en-US", + "message" : "stopping" + }, + { + "lang" : "ja-JP", + "message" : "停車" + }, + { + "lang" : "vi-VN", + "message" : "stopping" + } + ], + "default_lang" : "ja-JP" + }, + "keybind" : [ + ] + }, + { + "choice_id" : "running", + "name" : { + "messages" : [ + { + "lang" : "en-US", + "message" : "running" + }, + { + "lang" : "ja-JP", + "message" : "走行" + }, + { + "lang" : "vi-VN", + "message" : "running" + } + ], + "default_lang" : "ja-JP" + }, + "keybind" : [ + ] + } + ], + "metadata" : { + + } + }, + { + "additional_data_definition_id" : "2517f635-2269-4142-8ef4-16312b4cc9f7", + "read_only" : false, + "name" : { + "messages" : [ + { + "lang" : "en-US", + "message" : "occluded" + }, + { + "lang" : "ja-JP", + "message" : "occluded" + }, + { + "lang" : "vi-VN", + "message" : "occluded" + } + ], + "default_lang" : "ja-JP" + }, + "keybind" : [ + ], + "type" : "flag", + "default" : false, + "choices" : [ + ], + "metadata" : { + + } + }, + { + "additional_data_definition_id" : "9b05648d-1e16-4ea2-ab79-48907f5eed00", + "read_only" : false, + "name" : { + "messages" : [ + { + "lang" : "en-US", + "message" : "note" + }, + { + "lang" : "ja-JP", + "message" : "note" + }, + { + "lang" : "vi-VN", + "message" : "note" + } + ], + "default_lang" : "ja-JP" + }, + "keybind" : [ + ], + "type" : "text", + "default" : "", + "choices" : [ + ], + "metadata" : { + + } + }, + { + "additional_data_definition_id" : "e771ac4b-97d1-4af3-ba4b-f0e5b22e8648", + "read_only" : false, + "name" : { + "messages" : [ + { + "lang" : "en-US", + "message" : "truncated" + }, + { + "lang" : "ja-JP", + "message" : "truncated" + }, + { + "lang" : "vi-VN", + "message" : "truncated" + } + ], + "default_lang" : "ja-JP" + }, + "keybind" : [ + ], + "type" : "flag", + "default" : false, + "choices" : [ + ], + "metadata" : { + + } + } + ], + "restrictions" : [ + { + "additional_data_definition_id" : "15ba8b9d-4882-40c2-bb31-ed3f68197c2e", + "condition" : { + "labels" : [ + "9d6cca8d-3f5a-4808-a6c9-0ae18a478176" + ], + "_type" : "HasLabel" + } + }, + { + "additional_data_definition_id" : "d349e76d-b59a-44cd-94b4-713a00b2e84d", + "condition" : { + "value" : "", + "_type" : "NotEquals" + } + } + ], + "inspection_phrases" : [ + ], + "comment" : null, + "auto_marking" : false, + "annotation_type_version" : null, + "format_version" : "3.0.0", + "last_updated_datetime" : "2025-03-20T14:45:23.641+09:00", + "option" : { + "can_overwrap" : true + }, + "metadata" : { + + } + } \ No newline at end of file diff --git a/tests/util/test_local_attribute_restrictions.py b/tests/util/test_local_attribute_restrictions.py new file mode 100644 index 00000000..ddcfcf0d --- /dev/null +++ b/tests/util/test_local_attribute_restrictions.py @@ -0,0 +1,163 @@ +import json +from pathlib import Path + +import pytest + +from annofabapi.util.annotation_specs import AnnotationSpecsAccessor +from annofabapi.util.attribute_restrictions import AnnotationLink, Checkbox, IntegerTextBox, Selection, StringTextBox, TrackingId + +accessor = AnnotationSpecsAccessor(annotation_specs=json.loads(Path("tests/data/util/attribute_restrictions/annotation_specs.json").read_text())) + + +class Test__Checkbox: + def test__checked(self): + actual = Checkbox(accessor, attribute_id="2517f635-2269-4142-8ef4-16312b4cc9f7").checked().to_dict() + assert actual == {"additional_data_definition_id": "2517f635-2269-4142-8ef4-16312b4cc9f7", "condition": {"_type": "Equals", "value": "true"}} + + def test__unchecked(self): + actual = Checkbox(accessor, attribute_name="occluded").unchecked().to_dict() + assert actual == { + "additional_data_definition_id": "2517f635-2269-4142-8ef4-16312b4cc9f7", + "condition": {"_type": "NotEquals", "value": "true"}, + } + + def test__is_valid_attribute_type(self): + with pytest.raises(ValueError, match="属性の種類が'tracking'である属性は、クラス'Checkbox'では扱えません。"): + Checkbox(accessor, attribute_id="d349e76d-b59a-44cd-94b4-713a00b2e84d") + + +class Test__StringTextBox: + def test__matches(self): + actual = StringTextBox(accessor, attribute_name="note").matches("\\w").to_dict() + assert actual == {"additional_data_definition_id": "9b05648d-1e16-4ea2-ab79-48907f5eed00", "condition": {"_type": "Matches", "value": "\\w"}} + + def test__not_matches(self): + actual = StringTextBox(accessor, attribute_name="note").not_matches("\\w").to_dict() + assert actual == { + "additional_data_definition_id": "9b05648d-1e16-4ea2-ab79-48907f5eed00", + "condition": {"_type": "NotMatches", "value": "\\w"}, + } + + def test__equals(self): + actual = StringTextBox(accessor, attribute_name="note").equals("foo").to_dict() + assert actual == {"additional_data_definition_id": "9b05648d-1e16-4ea2-ab79-48907f5eed00", "condition": {"_type": "Equals", "value": "foo"}} + + def test__not_equals(self): + actual = StringTextBox(accessor, attribute_name="note").not_equals("foo").to_dict() + assert actual == { + "additional_data_definition_id": "9b05648d-1e16-4ea2-ab79-48907f5eed00", + "condition": {"_type": "NotEquals", "value": "foo"}, + } + + def test__is_empty(self): + actual = StringTextBox(accessor, attribute_name="note").is_empty().to_dict() + assert actual == {"additional_data_definition_id": "9b05648d-1e16-4ea2-ab79-48907f5eed00", "condition": {"_type": "Equals", "value": ""}} + + def test__is_not_empty(self): + actual = StringTextBox(accessor, attribute_name="note").is_not_empty().to_dict() + assert actual == {"additional_data_definition_id": "9b05648d-1e16-4ea2-ab79-48907f5eed00", "condition": {"_type": "NotEquals", "value": ""}} + + +class Test__IntegerTextBox: + def test__equals(self): + actual = IntegerTextBox(accessor, attribute_name="traffic_lane").equals(10).to_dict() + assert actual == {"additional_data_definition_id": "ec27de5d-122c-40e7-89bc-5500e37bae6a", "condition": {"_type": "Equals", "value": "10"}} + + def test__not_equals(self): + actual = IntegerTextBox(accessor, attribute_name="traffic_lane").not_equals(10).to_dict() + assert actual == {"additional_data_definition_id": "ec27de5d-122c-40e7-89bc-5500e37bae6a", "condition": {"_type": "NotEquals", "value": "10"}} + + +class Test__AnnotationLink: + def test__has_label(self): + actual = AnnotationLink(accessor, attribute_name="link_car").has_label(label_names=["car"]).to_dict() + assert actual == { + "additional_data_definition_id": "15ba8b9d-4882-40c2-bb31-ed3f68197c2e", + "condition": {"_type": "HasLabel", "labels": ["9d6cca8d-3f5a-4808-a6c9-0ae18a478176"]}, + } + + actual = AnnotationLink(accessor, attribute_name="link_car").has_label(label_ids=["9d6cca8d-3f5a-4808-a6c9-0ae18a478176"]).to_dict() + assert actual == { + "additional_data_definition_id": "15ba8b9d-4882-40c2-bb31-ed3f68197c2e", + "condition": {"_type": "HasLabel", "labels": ["9d6cca8d-3f5a-4808-a6c9-0ae18a478176"]}, + } + + +class Test__TrackingId: + def test__equals(self): + actual = TrackingId(accessor, attribute_name="tracking").equals("foo").to_dict() + assert actual == {"additional_data_definition_id": "d349e76d-b59a-44cd-94b4-713a00b2e84d", "condition": {"_type": "Equals", "value": "foo"}} + + def test__not_equals(self): + actual = TrackingId(accessor, attribute_name="tracking").not_equals("foo").to_dict() + assert actual == { + "additional_data_definition_id": "d349e76d-b59a-44cd-94b4-713a00b2e84d", + "condition": {"_type": "NotEquals", "value": "foo"}, + } + + +class Test__Selection: + def test__has_choice(self): + actual = Selection(accessor, attribute_name="car_kind").has_choice(choice_name="general_car").to_dict() + assert actual == { + "additional_data_definition_id": "cbb0155f-1631-48e1-8fc3-43c5f254b6f2", + "condition": {"_type": "Equals", "value": "7512ee39-8073-4e24-9b8c-93d99b76b7d2"}, + } + + def test__not_has_choice(self): + actual = Selection(accessor, attribute_name="car_kind").not_has_choice(choice_id="7512ee39-8073-4e24-9b8c-93d99b76b7d2").to_dict() + assert actual == { + "additional_data_definition_id": "cbb0155f-1631-48e1-8fc3-43c5f254b6f2", + "condition": {"_type": "NotEquals", "value": "7512ee39-8073-4e24-9b8c-93d99b76b7d2"}, + } + + +class Test__imply: + def test__occludedチェックボックスがONならばnoteテキストボックスは空ではない(self): + condition = Checkbox(accessor, attribute_name="occluded").checked().imply(StringTextBox(accessor, attribute_name="note").is_not_empty()) + actual = condition.to_dict() + assert actual == { + "additional_data_definition_id": "9b05648d-1e16-4ea2-ab79-48907f5eed00", + "condition": { + "_type": "Imply", + "premise": { + "additional_data_definition_id": "2517f635-2269-4142-8ef4-16312b4cc9f7", + "condition": {"_type": "Equals", "value": "true"}, + }, + "condition": {"_type": "NotEquals", "value": ""}, + }, + } + + def test__occludedチェックボックスがONかつtraffic_laneが2ならばnoteテキストボックスは空ではない(self): + condition = ( + Checkbox(accessor, attribute_name="occluded") + .checked() + .imply( + IntegerTextBox(accessor, attribute_name="traffic_lane").equals(2).imply(StringTextBox(accessor, attribute_name="note").is_not_empty()) + ) + ) + actual = condition.to_dict() + assert actual == { + "additional_data_definition_id": "9b05648d-1e16-4ea2-ab79-48907f5eed00", + "condition": { + "_type": "Imply", + "premise": { + "additional_data_definition_id": "2517f635-2269-4142-8ef4-16312b4cc9f7", + "condition": {"_type": "Equals", "value": "true"}, + }, + "condition": { + "_type": "Imply", + "premise": { + "additional_data_definition_id": "ec27de5d-122c-40e7-89bc-5500e37bae6a", + "condition": {"_type": "Equals", "value": "2"}, + }, + "condition": {"_type": "NotEquals", "value": ""}, + }, + }, + } + + def test__implyメソッドの戻りに対してimplyメソッドを実行するとNotImplementedErrorが発生する(self): + with pytest.raises(NotImplementedError): + Checkbox(accessor, attribute_name="occluded").checked().imply(IntegerTextBox(accessor, attribute_name="traffic_lane").equals(2)).imply( + StringTextBox(accessor, attribute_name="note").is_not_empty() + )