From 5c4f3a4de116928401ff4ed82c0cf020f084a131 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Tue, 28 Apr 2026 23:59:21 +0900 Subject: [PATCH 01/47] Add semantic AST helpers for attribute restrictions --- annofabapi/util/attribute_restrictions.py | 794 ++++++++++++++++++++-- tests/util/test_attribute_restrictions.py | 240 ++++++- 2 files changed, 982 insertions(+), 52 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index 6b7e79f8..33b30670 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -3,61 +3,40 @@ 以下のサンプルコードのように属性名で制約情報を出力できます。 -Examples: - .. code-block:: python - - >>> import annofabapi - >>> from annofabapi.util.attribute_restrictions import AttributeFactory - >>> service = annofabapi.build() - >>> annotation_specs, _ = service.api.get_annotation_specs("prj1", query_params={"v": "3"}) - >>> fac = AttributeFactory(annotation_specs) - - # 「'occluded'チェックボックスがONならば、'note'テキストボックスは空ではない」という制約 - >>> premise_restriction = fac.checkbox(attribute_name="occluded").checked() - >>> conclusion_restriction = fac.string_textbox(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": ""}, +Example: + >>> import annofabapi + >>> from annofabapi.util.attribute_restrictions import AttributeFactory + >>> service = annofabapi.build() + >>> annotation_specs, _ = service.api.get_annotation_specs("prj1", query_params={"v": "3"}) + >>> fac = AttributeFactory(annotation_specs) + + >>> premise_restriction = fac.checkbox(attribute_name="occluded").checked() + >>> conclusion_restriction = fac.string_textbox(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"}, }, - } - - # 「'occluded'チェックボックスがONならば、'car_kind'セレクトボックス(またはラジオボタン)は選択肢'general_car'を選択しない」という制約 - >>> premise_restriction = fac.checkbox(attribute_name="occluded").checked() - >>> conclusion_restriction = fac.selection(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"}, - }, - } + "condition": {"_type": "NotEquals", "value": ""}, + }, + } """ from abc import ABC, abstractmethod from collections.abc import Collection +from dataclasses import dataclass from typing import Any -from annofabapi.util.annotation_specs import AnnotationSpecsAccessor, get_choice +from annofabapi.util.annotation_specs import AnnotationSpecsAccessor, get_choice, get_english_message class Restriction(ABC): - """ - 属性の制約を表すクラス。 - """ + """属性の制約を表すクラス。""" def __init__(self, attribute_id: str) -> None: self.attribute_id = attribute_id @@ -65,13 +44,93 @@ def __init__(self, attribute_id: str) -> None: def to_dict(self) -> dict[str, Any]: """ アノテーション仕様の`restrictions`に格納できるdictを出力します。 + + Returns: + `restrictions` に格納できる辞書形式の制約情報です。 """ return {"additional_data_definition_id": self.attribute_id, "condition": self._to_dict_only_condition()} + @classmethod + def from_dict(cls, obj: dict[str, Any], annotation_specs: dict[str, Any] | None = None) -> "Restriction": + """ + dictからRestrictionオブジェクトを復元します。 + + Args: + obj: `restrictions` の1要素を表す辞書です。 + annotation_specs: Noneでなければ、アノテーション仕様を用いて属性型ごとの妥当性を検証します。 + + Returns: + 復元した `Restriction` オブジェクトです。 + + Raises: + ValueError: 制約の形式が不正な場合、または `annotation_specs` を使った妥当性検証に失敗した場合 + """ + fac = AttributeFactory(annotation_specs) if annotation_specs is not None else None + return _from_restriction_dict(obj, fac=fac) + + def to_python_expr(self, annotation_specs: dict[str, Any], *, factory_name: str = "fac") -> str: + """ + Restrictionオブジェクトを、高水準APIに近いPython式へ変換します。 + + Args: + annotation_specs: アノテーション仕様(v3)の情報です。 + factory_name: `AttributeFactory` の変数名です。 + + Returns: + 高水準APIに近い Python 式です。 + + Raises: + ValueError: 高水準APIの式へ変換できない制約が含まれている場合 + """ + accessor = AnnotationSpecsAccessor(annotation_specs) + return _restriction_to_python_expr(self, accessor=accessor, factory_name=factory_name) + + def to_ast(self, annotation_specs: dict[str, Any]) -> "RestrictionAst": + """ + Restrictionオブジェクトを、LLMやCLIで扱いやすい意味ベースのASTへ変換します。 + + Args: + annotation_specs: アノテーション仕様(v3)の情報です。 + + Returns: + 名前ベースで表現された `RestrictionAst` です。 + """ + accessor = AnnotationSpecsAccessor(annotation_specs) + return _restriction_to_ast(self, accessor=accessor) + + def to_human_readable(self, annotation_specs: dict[str, Any]) -> str: + """ + Restrictionオブジェクトを、人にとって読みやすい文字列表現へ変換します。 + + Args: + annotation_specs: アノテーション仕様(v3)の情報です。 + + Returns: + CLIなどで表示しやすい文字列表現です。 + """ + return self.to_ast(annotation_specs).to_human_readable() + + @classmethod + def from_ast(cls, ast: "RestrictionAst", annotation_specs: dict[str, Any]) -> "Restriction": + """ + 意味ベースのASTからRestrictionオブジェクトを復元します。 + + Args: + ast: 復元元の `RestrictionAst` です。 + annotation_specs: アノテーション仕様(v3)の情報です。 + + Returns: + 復元した `Restriction` オブジェクトです。 + """ + return ast.to_restriction(annotation_specs) + @abstractmethod def _to_dict_only_condition(self) -> dict[str, Any]: """ 制約の条件部分のみdictで出力します。 + + Returns: + 制約の条件部分のみを表す辞書です。 """ def imply(self, conclusion_restriction: "Restriction") -> "Restriction": @@ -80,11 +139,11 @@ def imply(self, conclusion_restriction: "Restriction") -> "Restriction": class Imply(Restriction): """ - 「AならB」という制約を表すクラス + 「AならB」という制約を表すクラス。 Args: - premise_restriction: 前提となる制約 - conclusion_restriction: 最終的に満たしたい制約 + premise_restriction: 前提となる制約です。 + conclusion_restriction: 最終的に満たしたい制約です。 """ def __init__(self, premise_restriction: Restriction, conclusion_restriction: Restriction) -> None: @@ -175,6 +234,10 @@ def __init__(self, accessor: AnnotationSpecsAccessor, *, attribute_id: str | Non if self._is_valid_attribute_type() is False: raise ValueError(f"属性の種類が'{self.attribute['type']}'である属性は、クラス'{self.__class__.__name__}'では扱えません。") + def enabled(self) -> Restriction: + """属性値を入力できるという制約""" + return CanInput(self.attribute_id, enable=True) + def disabled(self) -> Restriction: """属性値を入力できないという制約""" return CanInput(self.attribute_id, enable=False) @@ -291,10 +354,10 @@ def not_has_choice(self, *, choice_id: str | None = None, choice_name: str | Non class AttributeFactory: """ - 属性を生成するためのFactoryクラス + 属性を生成するためのFactoryクラス。 - Attributes: - annotation_specs: アノテーション仕様(v3)の情報 + Args: + annotation_specs: アノテーション仕様(v3)の情報です。 """ def __init__(self, annotation_specs: dict[str, Any]) -> None: @@ -317,3 +380,632 @@ def tracking_id(self, *, attribute_id: str | None = None, attribute_name: str | def selection(self, *, attribute_id: str | None = None, attribute_name: str | None = None) -> Selection: return Selection(self.accessor, attribute_id=attribute_id, attribute_name=attribute_name) + + +@dataclass(frozen=True) +class RestrictionAst: + """ + LLMやCLI向けの意味ベースな属性制約ASTを表すクラス。 + + `type` に応じて必要なフィールドが変わります。例えば `checked` では + `attribute_name` を使い、`imply` では `premise` と `conclusion` を使います。 + + Args: + type: ASTノードの種類です。 + attribute_name: 対象属性の名前です。 + value: 文字列や整数の比較値です。 + choice_name: 選択肢名です。 + enable: `can_input` ノードで使う真偽値です。 + label_names: `has_label` ノードで使うラベル名の一覧です。 + premise: `imply` ノードの前提です。 + conclusion: `imply` ノードの結論です。 + """ + + type: str + attribute_name: str | None = None + value: str | int | None = None + choice_name: str | None = None + enable: bool | None = None + label_names: list[str] | None = None + premise: "RestrictionAst | None" = None + conclusion: "RestrictionAst | None" = None + + def __post_init__(self) -> None: + _validate_restriction_ast(self) + + def to_dict(self) -> dict[str, Any]: + """ + ASTをJSONシリアライズしやすい辞書へ変換します。 + + Returns: + 辞書形式のASTです。 + """ + result: dict[str, Any] = {"type": self.type} + if self.attribute_name is not None: + result["attribute_name"] = self.attribute_name + if self.value is not None: + result["value"] = self.value + if self.choice_name is not None: + result["choice_name"] = self.choice_name + if self.enable is not None: + result["enable"] = self.enable + if self.label_names is not None: + result["label_names"] = self.label_names + if self.premise is not None: + result["premise"] = self.premise.to_dict() + if self.conclusion is not None: + result["conclusion"] = self.conclusion.to_dict() + return result + + @classmethod + def from_dict(cls, obj: dict[str, Any]) -> "RestrictionAst": + """ + 辞書からASTを復元します。 + + Args: + obj: ASTを表す辞書です。 + + Returns: + 復元した `RestrictionAst` です。 + """ + premise = cls.from_dict(obj["premise"]) if obj.get("premise") is not None else None + conclusion = cls.from_dict(obj["conclusion"]) if obj.get("conclusion") is not None else None + return cls( + type=obj["type"], + attribute_name=obj.get("attribute_name"), + value=obj.get("value"), + choice_name=obj.get("choice_name"), + enable=obj.get("enable"), + label_names=obj.get("label_names"), + premise=premise, + conclusion=conclusion, + ) + + def to_restriction(self, annotation_specs: dict[str, Any]) -> Restriction: + """ + ASTをRestrictionオブジェクトへコンパイルします。 + + Args: + annotation_specs: アノテーション仕様(v3)の情報です。 + + Returns: + コンパイル後の `Restriction` オブジェクトです。 + """ + fac = AttributeFactory(annotation_specs) + return _ast_to_restriction(self, fac=fac) + + def to_human_readable(self) -> str: + """ + ASTを人間向けの読みやすい文字列へ変換します。 + + Returns: + CLIなどで表示しやすい文字列表現です。 + """ + return _ast_to_human_readable(self) + + +def get_attribute_restriction_catalog(annotation_specs: dict[str, Any]) -> list[dict[str, Any]]: + """ + 属性制約ASTを組み立てるための属性カタログを返します。 + + Args: + annotation_specs: アノテーション仕様(v3)の情報です。 + + Returns: + LLMへのプロンプトや入力候補生成に使いやすい属性カタログです。 + """ + accessor = AnnotationSpecsAccessor(annotation_specs) + catalog = [] + for attribute in accessor.additionals: + attribute_type = attribute["type"] + item: dict[str, Any] = { + "attribute_name": get_english_message(attribute["name"]), + "attribute_type": attribute_type, + "allowed_ast_types": _get_allowed_ast_types(attribute_type), + } + if attribute_type in {"choice", "select"}: + item["choice_names"] = [get_english_message(choice["name"]) for choice in attribute["choices"]] + if attribute_type == "link": + item["label_names"] = [get_english_message(label["label_name"]) for label in accessor.labels] + catalog.append(item) + return catalog + + +def _from_restriction_dict(obj: dict[str, Any], *, fac: AttributeFactory | None) -> Restriction: + attribute_id = obj["additional_data_definition_id"] + condition = obj["condition"] + return _from_condition_dict(attribute_id=attribute_id, condition=condition, fac=fac) + + +def _validate_restriction_ast(ast: RestrictionAst) -> None: + type_to_fields = { + "checked": {"attribute_name"}, + "unchecked": {"attribute_name"}, + "is_empty": {"attribute_name"}, + "is_not_empty": {"attribute_name"}, + "equals_string": {"attribute_name", "value"}, + "not_equals_string": {"attribute_name", "value"}, + "matches_string": {"attribute_name", "value"}, + "not_matches_string": {"attribute_name", "value"}, + "equals_integer": {"attribute_name", "value"}, + "not_equals_integer": {"attribute_name", "value"}, + "has_choice": {"attribute_name", "choice_name"}, + "not_has_choice": {"attribute_name", "choice_name"}, + "has_label": {"attribute_name", "label_names"}, + "can_input": {"attribute_name", "enable"}, + "imply": {"premise", "conclusion"}, + } + required_fields = type_to_fields.get(ast.type) + if required_fields is None: + raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") + + actual_fields = { + field_name + for field_name, value in ( + ("attribute_name", ast.attribute_name), + ("value", ast.value), + ("choice_name", ast.choice_name), + ("enable", ast.enable), + ("label_names", ast.label_names), + ("premise", ast.premise), + ("conclusion", ast.conclusion), + ) + if value is not None + } + if actual_fields != required_fields: + raise ValueError( + f"AST種別'{ast.type}'のフィールドが不正です。 :: required={sorted(required_fields)}, actual={sorted(actual_fields)}" + ) + + if ast.type in {"equals_string", "not_equals_string", "matches_string", "not_matches_string"} and not isinstance(ast.value, str): + raise ValueError(f"AST種別'{ast.type}'の'value'は文字列である必要があります。") + if ast.type in {"equals_integer", "not_equals_integer"} and not isinstance(ast.value, int): + raise ValueError(f"AST種別'{ast.type}'の'value'は整数である必要があります。") + if ast.type in {"has_choice", "not_has_choice"} and not isinstance(ast.choice_name, str): + raise ValueError(f"AST種別'{ast.type}'の'choice_name'は文字列である必要があります。") + if ast.type == "has_label": + if not isinstance(ast.label_names, list) or any(not isinstance(label_name, str) for label_name in ast.label_names): + raise ValueError("AST種別'has_label'の'label_names'は文字列のリストである必要があります。") + if ast.type == "can_input" and not isinstance(ast.enable, bool): + raise ValueError("AST種別'can_input'の'enable'は真偽値である必要があります。") + + +def _get_allowed_ast_types(attribute_type: str) -> list[str]: + if attribute_type == "flag": + return ["can_input", "checked", "unchecked"] + if attribute_type in {"text", "comment"}: + return ["can_input", "is_empty", "is_not_empty", "equals_string", "not_equals_string", "matches_string", "not_matches_string"] + if attribute_type == "integer": + return ["can_input", "is_empty", "is_not_empty", "equals_integer", "not_equals_integer"] + if attribute_type == "link": + return ["can_input", "is_empty", "is_not_empty", "has_label"] + if attribute_type == "tracking": + return ["can_input", "is_empty", "is_not_empty", "equals_string", "not_equals_string"] + if attribute_type in {"choice", "select"}: + return ["can_input", "is_empty", "is_not_empty", "has_choice", "not_has_choice"] + raise ValueError(f"未対応の属性種類です。 :: attribute_type='{attribute_type}'") + + +def _from_condition_dict(*, attribute_id: str, condition: dict[str, Any], fac: AttributeFactory | None) -> Restriction: + condition_type = condition["_type"] + if condition_type == "Imply": + premise_restriction = _from_restriction_dict(condition["premise"], fac=fac) + conclusion_restriction = _from_condition_dict(attribute_id=attribute_id, condition=condition["condition"], fac=fac) + return Imply(premise_restriction=premise_restriction, conclusion_restriction=conclusion_restriction) + + if fac is None: + return _from_condition_dict_without_validation(attribute_id=attribute_id, condition=condition) + return _from_condition_dict_with_validation(attribute_id=attribute_id, condition=condition, fac=fac) + + +def _from_condition_dict_without_validation(*, attribute_id: str, condition: dict[str, Any]) -> Restriction: + condition_type = condition["_type"] + if condition_type == "CanInput": + return CanInput(attribute_id, enable=condition["enable"]) + if condition_type == "Equals": + return Equals(attribute_id, value=condition["value"]) + if condition_type == "NotEquals": + return NotEquals(attribute_id, value=condition["value"]) + if condition_type == "Matches": + return Matches(attribute_id, value=condition["value"]) + if condition_type == "NotMatches": + return NotMatches(attribute_id, value=condition["value"]) + if condition_type == "HasLabel": + return HasLabel(attribute_id, label_ids=condition["labels"]) + raise ValueError(f"未知の制約種別です。 :: _type='{condition_type}'") + + +def _from_condition_dict_with_validation(*, attribute_id: str, condition: dict[str, Any], fac: AttributeFactory) -> Restriction: + attribute = fac.accessor.get_attribute(attribute_id=attribute_id) + attribute_obj = _create_attribute_object(fac, attribute) + attribute_type = attribute["type"] + condition_type = condition["_type"] + + if condition_type == "CanInput": + return attribute_obj.enabled() if condition["enable"] else attribute_obj.disabled() + + if attribute_type == "flag": + if condition_type == "Equals" and condition["value"] == "true": + return attribute_obj.checked() + if condition_type == "NotEquals" and condition["value"] == "true": + return attribute_obj.unchecked() + _raise_invalid_restriction(attribute=attribute, condition=condition) + + if attribute_type in {"text", "comment"}: + if condition_type == "Equals": + return attribute_obj.equals(condition["value"]) + if condition_type == "NotEquals": + return attribute_obj.not_equals(condition["value"]) + if condition_type == "Matches": + return attribute_obj.matches(condition["value"]) + if condition_type == "NotMatches": + return attribute_obj.not_matches(condition["value"]) + _raise_invalid_restriction(attribute=attribute, condition=condition) + + if attribute_type == "integer": + if condition_type == "Equals": + if condition["value"] == "": + return attribute_obj.is_empty() + return attribute_obj.equals(_parse_integer_value(condition["value"], attribute=attribute, condition=condition)) + if condition_type == "NotEquals": + if condition["value"] == "": + return attribute_obj.is_not_empty() + return attribute_obj.not_equals(_parse_integer_value(condition["value"], attribute=attribute, condition=condition)) + _raise_invalid_restriction(attribute=attribute, condition=condition) + + if attribute_type == "link": + if condition_type == "HasLabel": + return attribute_obj.has_label(label_ids=condition["labels"]) + if condition_type == "Equals" and condition["value"] == "": + return attribute_obj.is_empty() + if condition_type == "NotEquals" and condition["value"] == "": + return attribute_obj.is_not_empty() + _raise_invalid_restriction(attribute=attribute, condition=condition) + + if attribute_type == "tracking": + if condition_type == "Equals": + if condition["value"] == "": + return attribute_obj.is_empty() + return attribute_obj.equals(condition["value"]) + if condition_type == "NotEquals": + if condition["value"] == "": + return attribute_obj.is_not_empty() + return attribute_obj.not_equals(condition["value"]) + _raise_invalid_restriction(attribute=attribute, condition=condition) + + if attribute_type in {"choice", "select"}: + if condition_type == "Equals": + if condition["value"] == "": + return attribute_obj.is_empty() + return attribute_obj.has_choice(choice_id=condition["value"]) + if condition_type == "NotEquals": + if condition["value"] == "": + return attribute_obj.is_not_empty() + return attribute_obj.not_has_choice(choice_id=condition["value"]) + _raise_invalid_restriction(attribute=attribute, condition=condition) + + raise ValueError(f"未対応の属性種類です。 :: attribute_type='{attribute_type}'") + + +def _create_attribute_object(fac: AttributeFactory, attribute: dict[str, Any]) -> Attribute: + attribute_id = attribute["additional_data_definition_id"] + attribute_type = attribute["type"] + if attribute_type == "flag": + return fac.checkbox(attribute_id=attribute_id) + if attribute_type in {"text", "comment"}: + return fac.string_textbox(attribute_id=attribute_id) + if attribute_type == "integer": + return fac.integer_textbox(attribute_id=attribute_id) + if attribute_type == "link": + return fac.annotation_link(attribute_id=attribute_id) + if attribute_type == "tracking": + return fac.tracking_id(attribute_id=attribute_id) + if attribute_type in {"choice", "select"}: + return fac.selection(attribute_id=attribute_id) + raise ValueError(f"未対応の属性種類です。 :: attribute_type='{attribute_type}'") + + +def _create_attribute_object_with_name(fac: AttributeFactory, attribute_name: str) -> Attribute: + attribute = fac.accessor.get_attribute(attribute_name=attribute_name) + return _create_attribute_object(fac, attribute) + + +def _ast_to_restriction(ast: RestrictionAst, *, fac: AttributeFactory) -> Restriction: + if ast.type == "imply": + assert ast.premise is not None + assert ast.conclusion is not None + premise_restriction = _ast_to_restriction(ast.premise, fac=fac) + conclusion_restriction = _ast_to_restriction(ast.conclusion, fac=fac) + return premise_restriction.imply(conclusion_restriction) + + assert ast.attribute_name is not None + attribute = fac.accessor.get_attribute(attribute_name=ast.attribute_name) + attribute_type = attribute["type"] + + if ast.type == "checked": + return fac.checkbox(attribute_name=ast.attribute_name).checked() + if ast.type == "unchecked": + return fac.checkbox(attribute_name=ast.attribute_name).unchecked() + if ast.type == "is_empty": + return _attribute_with_empty_check(fac, ast.attribute_name).is_empty() + if ast.type == "is_not_empty": + return _attribute_with_empty_check(fac, ast.attribute_name).is_not_empty() + if ast.type == "can_input": + assert ast.enable is not None + attribute_obj = _create_attribute_object_with_name(fac, ast.attribute_name) + return attribute_obj.enabled() if ast.enable else attribute_obj.disabled() + if ast.type == "equals_string": + assert isinstance(ast.value, str) + if attribute_type in {"text", "comment"}: + return fac.string_textbox(attribute_name=ast.attribute_name).equals(ast.value) + if attribute_type == "tracking": + return fac.tracking_id(attribute_name=ast.attribute_name).equals(ast.value) + _raise_invalid_ast(attribute=attribute, ast=ast) + if ast.type == "not_equals_string": + assert isinstance(ast.value, str) + if attribute_type in {"text", "comment"}: + return fac.string_textbox(attribute_name=ast.attribute_name).not_equals(ast.value) + if attribute_type == "tracking": + return fac.tracking_id(attribute_name=ast.attribute_name).not_equals(ast.value) + _raise_invalid_ast(attribute=attribute, ast=ast) + if ast.type == "matches_string": + assert isinstance(ast.value, str) + if attribute_type in {"text", "comment"}: + return fac.string_textbox(attribute_name=ast.attribute_name).matches(ast.value) + _raise_invalid_ast(attribute=attribute, ast=ast) + if ast.type == "not_matches_string": + assert isinstance(ast.value, str) + if attribute_type in {"text", "comment"}: + return fac.string_textbox(attribute_name=ast.attribute_name).not_matches(ast.value) + _raise_invalid_ast(attribute=attribute, ast=ast) + if ast.type == "equals_integer": + assert isinstance(ast.value, int) + return fac.integer_textbox(attribute_name=ast.attribute_name).equals(ast.value) + if ast.type == "not_equals_integer": + assert isinstance(ast.value, int) + return fac.integer_textbox(attribute_name=ast.attribute_name).not_equals(ast.value) + if ast.type == "has_choice": + assert ast.choice_name is not None + return fac.selection(attribute_name=ast.attribute_name).has_choice(choice_name=ast.choice_name) + if ast.type == "not_has_choice": + assert ast.choice_name is not None + return fac.selection(attribute_name=ast.attribute_name).not_has_choice(choice_name=ast.choice_name) + if ast.type == "has_label": + assert ast.label_names is not None + return fac.annotation_link(attribute_name=ast.attribute_name).has_label(label_names=ast.label_names) + + raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") + + +def _attribute_with_empty_check(fac: AttributeFactory, attribute_name: str) -> EmptyCheckMixin: + attribute_obj = _create_attribute_object_with_name(fac, attribute_name) + if not isinstance(attribute_obj, EmptyCheckMixin): + attribute = fac.accessor.get_attribute(attribute_name=attribute_name) + _raise_invalid_restriction( + attribute=attribute, + condition={"_type": "EmptyCheck"}, + detail="空判定はこの属性種類では利用できません。", + ) + return attribute_obj + + +def _raise_invalid_ast(*, attribute: dict[str, Any], ast: RestrictionAst) -> None: + attribute_name = get_english_message(attribute["name"]) + raise ValueError(f"属性'{attribute_name}'(type='{attribute['type']}')ではAST種別'{ast.type}'を利用できません。") + + +def _parse_integer_value(value: str, *, attribute: dict[str, Any], condition: dict[str, Any]) -> int: + try: + return int(value) + except ValueError as exc: + _raise_invalid_restriction(attribute=attribute, condition=condition, detail="整数属性には整数値を指定してください。") + raise AssertionError("unreachable") from exc + + +def _raise_invalid_restriction(*, attribute: dict[str, Any], condition: dict[str, Any], detail: str | None = None) -> None: + attribute_name = get_english_message(attribute["name"]) + message = f"属性'{attribute_name}'(type='{attribute['type']}')では制約'{condition['_type']}'を利用できません。" + if detail is not None: + message += f" {detail}" + raise ValueError(message) + + +def _restriction_to_ast(restriction: Restriction, *, accessor: AnnotationSpecsAccessor) -> RestrictionAst: + if isinstance(restriction, Imply): + return RestrictionAst( + type="imply", + premise=_restriction_to_ast(restriction.premise_restriction, accessor=accessor), + conclusion=_restriction_to_ast(restriction.conclusion_restriction, accessor=accessor), + ) + + attribute = accessor.get_attribute(attribute_id=restriction.attribute_id) + attribute_name = get_english_message(attribute["name"]) + attribute_type = attribute["type"] + + if isinstance(restriction, CanInput): + return RestrictionAst(type="can_input", attribute_name=attribute_name, enable=restriction.enable) + + if isinstance(restriction, Equals): + if attribute_type == "flag" and restriction.value == "true": + return RestrictionAst(type="checked", attribute_name=attribute_name) + if restriction.value == "" and attribute_type in {"text", "comment", "integer", "link", "tracking", "choice", "select"}: + return RestrictionAst(type="is_empty", attribute_name=attribute_name) + if attribute_type in {"text", "comment", "tracking"}: + return RestrictionAst(type="equals_string", attribute_name=attribute_name, value=restriction.value) + if attribute_type == "integer": + return RestrictionAst(type="equals_integer", attribute_name=attribute_name, value=int(restriction.value)) + if attribute_type in {"choice", "select"}: + choice = get_choice(attribute["choices"], choice_id=restriction.value) + return RestrictionAst(type="has_choice", attribute_name=attribute_name, choice_name=get_english_message(choice["name"])) + + if isinstance(restriction, NotEquals): + if attribute_type == "flag" and restriction.value == "true": + return RestrictionAst(type="unchecked", attribute_name=attribute_name) + if restriction.value == "" and attribute_type in {"text", "comment", "integer", "link", "tracking", "choice", "select"}: + return RestrictionAst(type="is_not_empty", attribute_name=attribute_name) + if attribute_type in {"text", "comment", "tracking"}: + return RestrictionAst(type="not_equals_string", attribute_name=attribute_name, value=restriction.value) + if attribute_type == "integer": + return RestrictionAst(type="not_equals_integer", attribute_name=attribute_name, value=int(restriction.value)) + if attribute_type in {"choice", "select"}: + choice = get_choice(attribute["choices"], choice_id=restriction.value) + return RestrictionAst(type="not_has_choice", attribute_name=attribute_name, choice_name=get_english_message(choice["name"])) + + if isinstance(restriction, Matches) and attribute_type in {"text", "comment"}: + return RestrictionAst(type="matches_string", attribute_name=attribute_name, value=restriction.value) + + if isinstance(restriction, NotMatches) and attribute_type in {"text", "comment"}: + return RestrictionAst(type="not_matches_string", attribute_name=attribute_name, value=restriction.value) + + if isinstance(restriction, HasLabel) and attribute_type == "link": + label_names = [get_english_message(accessor.get_label(label_id=label_id)["label_name"]) for label_id in restriction.label_ids] + return RestrictionAst(type="has_label", attribute_name=attribute_name, label_names=label_names) + + raise ValueError(f"RestrictionをASTへ変換できません。 :: restriction={restriction.to_dict()}") + + +def _restriction_to_python_expr(restriction: Restriction, *, accessor: AnnotationSpecsAccessor, factory_name: str) -> str: + if isinstance(restriction, Imply): + premise_expr = _restriction_to_python_expr(restriction.premise_restriction, accessor=accessor, factory_name=factory_name) + conclusion_expr = _restriction_to_python_expr(restriction.conclusion_restriction, accessor=accessor, factory_name=factory_name) + return f"{premise_expr}.imply({conclusion_expr})" + + attribute = accessor.get_attribute(attribute_id=restriction.attribute_id) + attribute_expr = _attribute_to_python_expr(attribute, factory_name=factory_name) + attribute_type = attribute["type"] + + if isinstance(restriction, CanInput): + return f"{attribute_expr}.enabled()" if restriction.enable else f"{attribute_expr}.disabled()" + + if isinstance(restriction, Equals): + if attribute_type == "flag" and restriction.value == "true": + return f"{attribute_expr}.checked()" + if attribute_type in {"text", "comment"}: + if restriction.value == "": + return f"{attribute_expr}.is_empty()" + return f"{attribute_expr}.equals({_repr_python_value(restriction.value)})" + if attribute_type == "integer": + if restriction.value == "": + return f"{attribute_expr}.is_empty()" + return f"{attribute_expr}.equals({int(restriction.value)})" + if attribute_type == "link" and restriction.value == "": + return f"{attribute_expr}.is_empty()" + if attribute_type == "tracking": + if restriction.value == "": + return f"{attribute_expr}.is_empty()" + return f"{attribute_expr}.equals({_repr_python_value(restriction.value)})" + if attribute_type in {"choice", "select"}: + if restriction.value == "": + return f"{attribute_expr}.is_empty()" + choice = get_choice(attribute["choices"], choice_id=restriction.value) + choice_name = get_english_message(choice["name"]) + return f"{attribute_expr}.has_choice(choice_name={_repr_python_value(choice_name)})" + + if isinstance(restriction, NotEquals): + if attribute_type == "flag" and restriction.value == "true": + return f"{attribute_expr}.unchecked()" + if attribute_type in {"text", "comment"}: + if restriction.value == "": + return f"{attribute_expr}.is_not_empty()" + return f"{attribute_expr}.not_equals({_repr_python_value(restriction.value)})" + if attribute_type == "integer": + if restriction.value == "": + return f"{attribute_expr}.is_not_empty()" + return f"{attribute_expr}.not_equals({int(restriction.value)})" + if attribute_type == "link" and restriction.value == "": + return f"{attribute_expr}.is_not_empty()" + if attribute_type == "tracking": + if restriction.value == "": + return f"{attribute_expr}.is_not_empty()" + return f"{attribute_expr}.not_equals({_repr_python_value(restriction.value)})" + if attribute_type in {"choice", "select"}: + if restriction.value == "": + return f"{attribute_expr}.is_not_empty()" + choice = get_choice(attribute["choices"], choice_id=restriction.value) + choice_name = get_english_message(choice["name"]) + return f"{attribute_expr}.not_has_choice(choice_name={_repr_python_value(choice_name)})" + + if isinstance(restriction, Matches) and attribute_type in {"text", "comment"}: + return f"{attribute_expr}.matches({_repr_python_value(restriction.value)})" + + if isinstance(restriction, NotMatches) and attribute_type in {"text", "comment"}: + return f"{attribute_expr}.not_matches({_repr_python_value(restriction.value)})" + + if isinstance(restriction, HasLabel) and attribute_type == "link": + label_names = [get_english_message(accessor.get_label(label_id=label_id)["label_name"]) for label_id in restriction.label_ids] + return f"{attribute_expr}.has_label(label_names={_repr_python_value(label_names)})" + + raise ValueError(f"Restrictionを高水準APIのPython式へ変換できません。 :: restriction={restriction.to_dict()}") + + +def _attribute_to_python_expr(attribute: dict[str, Any], *, factory_name: str) -> str: + attribute_name = get_english_message(attribute["name"]) + attribute_type = attribute["type"] + + if attribute_type == "flag": + factory_method = "checkbox" + elif attribute_type in {"text", "comment"}: + factory_method = "string_textbox" + elif attribute_type == "integer": + factory_method = "integer_textbox" + elif attribute_type == "link": + factory_method = "annotation_link" + elif attribute_type == "tracking": + factory_method = "tracking_id" + elif attribute_type in {"choice", "select"}: + factory_method = "selection" + else: + raise ValueError(f"未対応の属性種類です。 :: attribute_type='{attribute_type}'") + + return f"{factory_name}.{factory_method}(attribute_name={_repr_python_value(attribute_name)})" + + +def _ast_to_human_readable(ast: RestrictionAst) -> str: + if ast.type == "imply": + assert ast.premise is not None + assert ast.conclusion is not None + return f"{ast.conclusion.to_human_readable()} IF {ast.premise.to_human_readable()}" + + assert ast.attribute_name is not None + attribute_name = _quote_human(ast.attribute_name) + + if ast.type == "checked": + return f"{attribute_name} EQUALS 'true'" + if ast.type == "unchecked": + return f"{attribute_name} DOES NOT EQUAL 'true'" + if ast.type == "is_empty": + return f"{attribute_name} EQUALS ''" + if ast.type == "is_not_empty": + return f"{attribute_name} DOES NOT EQUAL ''" + if ast.type == "equals_string": + return f"{attribute_name} EQUALS {_quote_human(ast.value)}" + if ast.type == "not_equals_string": + return f"{attribute_name} DOES NOT EQUAL {_quote_human(ast.value)}" + if ast.type == "matches_string": + return f"{attribute_name} MATCHES {_quote_human(ast.value)}" + if ast.type == "not_matches_string": + return f"{attribute_name} DOES NOT MATCH {_quote_human(ast.value)}" + if ast.type == "equals_integer": + return f"{attribute_name} EQUALS {_quote_human(ast.value)}" + if ast.type == "not_equals_integer": + return f"{attribute_name} DOES NOT EQUAL {_quote_human(ast.value)}" + if ast.type == "has_choice": + return f"{attribute_name} EQUALS {_quote_human(ast.choice_name)}" + if ast.type == "not_has_choice": + return f"{attribute_name} DOES NOT EQUAL {_quote_human(ast.choice_name)}" + if ast.type == "has_label": + assert ast.label_names is not None + return f"{attribute_name} HAS LABEL {', '.join(_quote_human(label_name) for label_name in ast.label_names)}" + if ast.type == "can_input": + assert ast.enable is not None + return f"{attribute_name} CAN INPUT" if ast.enable else f"{attribute_name} CANNOT INPUT" + + raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") + + +def _repr_python_value(value: Any) -> str: + return repr(value) + + +def _quote_human(value: Any) -> str: + return f"'{value}'" diff --git a/tests/util/test_attribute_restrictions.py b/tests/util/test_attribute_restrictions.py index 2367a6c7..8a5c1bec 100644 --- a/tests/util/test_attribute_restrictions.py +++ b/tests/util/test_attribute_restrictions.py @@ -4,7 +4,18 @@ import pytest from annofabapi.util.annotation_specs import AnnotationSpecsAccessor -from annofabapi.util.attribute_restrictions import AnnotationLink, AttributeFactory, Checkbox, IntegerTextbox, Selection, StringTextbox, TrackingId +from annofabapi.util.attribute_restrictions import ( + AnnotationLink, + AttributeFactory, + Checkbox, + IntegerTextbox, + Restriction, + RestrictionAst, + Selection, + StringTextbox, + TrackingId, + get_attribute_restriction_catalog, +) accessor = AnnotationSpecsAccessor(annotation_specs=json.loads(Path("tests/data/util/attribute_restrictions/annotation_specs.json").read_text())) @@ -197,3 +208,230 @@ def test__selection(self): selection = self.factory.selection(attribute_name="car_kind") assert isinstance(selection, Selection) assert selection.attribute_id == "cbb0155f-1631-48e1-8fc3-43c5f254b6f2" + + +class Test__Restriction: + def test__from_dict(self): + restriction_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": ""}, + }, + } + + actual = Restriction.from_dict(restriction_dict, annotation_specs=accessor.annotation_specs) + + assert actual.to_dict() == restriction_dict + + def test__to_python_expr(self): + restriction_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": ""}, + }, + } + restriction = Restriction.from_dict(restriction_dict, annotation_specs=accessor.annotation_specs) + + actual = restriction.to_python_expr(accessor.annotation_specs) + + assert actual == "fac.checkbox(attribute_name='occluded').checked().imply(fac.string_textbox(attribute_name='note').is_not_empty())" + + def test__to_python_expr__selection(self): + restriction = Restriction.from_dict( + { + "additional_data_definition_id": "cbb0155f-1631-48e1-8fc3-43c5f254b6f2", + "condition": {"_type": "NotEquals", "value": "7512ee39-8073-4e24-9b8c-93d99b76b7d2"}, + }, + annotation_specs=accessor.annotation_specs, + ) + + actual = restriction.to_python_expr(accessor.annotation_specs) + + assert actual == "fac.selection(attribute_name='car_kind').not_has_choice(choice_name='general_car')" + + def test__to_ast(self): + restriction = Restriction.from_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": ""}, + }, + }, + annotation_specs=accessor.annotation_specs, + ) + + actual = restriction.to_ast(accessor.annotation_specs) + + assert actual == RestrictionAst( + type="imply", + premise=RestrictionAst(type="checked", attribute_name="occluded"), + conclusion=RestrictionAst(type="is_not_empty", attribute_name="note"), + ) + + def test__to_human_readable(self): + restriction = Restriction.from_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": ""}, + }, + }, + annotation_specs=accessor.annotation_specs, + ) + + actual = restriction.to_human_readable(accessor.annotation_specs) + + assert actual == "'note' DOES NOT EQUAL '' IF 'occluded' EQUALS 'true'" + + def test__from_dict__annotation_specsを指定しない場合は妥当性検証しない(self): + restriction_dict = { + "additional_data_definition_id": "d349e76d-b59a-44cd-94b4-713a00b2e84d", + "condition": {"_type": "Matches", "value": "\\d+"}, + } + + actual = Restriction.from_dict(restriction_dict) + + assert actual.to_dict() == restriction_dict + + def test__from_dict__tracking_id属性にmatchesは指定できない(self): + restriction_dict = { + "additional_data_definition_id": "d349e76d-b59a-44cd-94b4-713a00b2e84d", + "condition": {"_type": "Matches", "value": "\\d+"}, + } + + with pytest.raises(ValueError, match="属性'tracking'\\(type='tracking'\\)では制約'Matches'を利用できません。"): + Restriction.from_dict(restriction_dict, annotation_specs=accessor.annotation_specs) + + def test__from_dict__integer属性に整数以外の値は指定できない(self): + restriction_dict = { + "additional_data_definition_id": "ec27de5d-122c-40e7-89bc-5500e37bae6a", + "condition": {"_type": "Equals", "value": "foo"}, + } + + with pytest.raises(ValueError, match="整数属性には整数値を指定してください。"): + Restriction.from_dict(restriction_dict, annotation_specs=accessor.annotation_specs) + + def test__from_dict__can_input_true(self): + restriction_dict = { + "additional_data_definition_id": "2517f635-2269-4142-8ef4-16312b4cc9f7", + "condition": {"_type": "CanInput", "enable": True}, + } + restriction = Restriction.from_dict(restriction_dict, annotation_specs=accessor.annotation_specs) + + assert restriction.to_dict() == restriction_dict + assert restriction.to_python_expr(accessor.annotation_specs) == "fac.checkbox(attribute_name='occluded').enabled()" + + def test__from_ast(self): + ast = RestrictionAst( + type="imply", + premise=RestrictionAst(type="checked", attribute_name="occluded"), + conclusion=RestrictionAst(type="has_choice", attribute_name="car_kind", choice_name="general_car"), + ) + + actual = Restriction.from_ast(ast, accessor.annotation_specs) + + assert actual.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"}, + }, + } + + +class Test__RestrictionAst: + def test__to_dict(self): + ast = RestrictionAst( + type="imply", + premise=RestrictionAst(type="checked", attribute_name="occluded"), + conclusion=RestrictionAst(type="is_not_empty", attribute_name="note"), + ) + + assert ast.to_dict() == { + "type": "imply", + "premise": {"type": "checked", "attribute_name": "occluded"}, + "conclusion": {"type": "is_not_empty", "attribute_name": "note"}, + } + + def test__from_dict(self): + actual = RestrictionAst.from_dict( + { + "type": "imply", + "premise": {"type": "checked", "attribute_name": "occluded"}, + "conclusion": {"type": "has_choice", "attribute_name": "car_kind", "choice_name": "general_car"}, + } + ) + + assert actual == RestrictionAst( + type="imply", + premise=RestrictionAst(type="checked", attribute_name="occluded"), + conclusion=RestrictionAst(type="has_choice", attribute_name="car_kind", choice_name="general_car"), + ) + + def test__to_restriction(self): + ast = RestrictionAst(type="matches_string", attribute_name="note", value="[abc]+") + + actual = ast.to_restriction(accessor.annotation_specs) + + assert actual.to_dict() == { + "additional_data_definition_id": "9b05648d-1e16-4ea2-ab79-48907f5eed00", + "condition": {"_type": "Matches", "value": "[abc]+"}, + } + + def test__to_restriction__trackingにはmatches_stringを指定できない(self): + ast = RestrictionAst(type="matches_string", attribute_name="tracking", value="foo") + + with pytest.raises(ValueError, match="属性'tracking'\\(type='tracking'\\)ではAST種別'matches_string'を利用できません。"): + ast.to_restriction(accessor.annotation_specs) + + def test__to_human_readable(self): + ast = RestrictionAst(type="has_label", attribute_name="link_car", label_names=["car", "number_plate"]) + + actual = ast.to_human_readable() + + assert actual == "'link_car' HAS LABEL 'car', 'number_plate'" + + def test__invalid_fields(self): + with pytest.raises(ValueError, match="required=.*attribute_name.*value"): + RestrictionAst(type="equals_string", attribute_name="note") + + +class Test__get_attribute_restriction_catalog: + def test__catalog(self): + actual = get_attribute_restriction_catalog(accessor.annotation_specs) + + assert { + "attribute_name": "tracking", + "attribute_type": "tracking", + "allowed_ast_types": ["can_input", "is_empty", "is_not_empty", "equals_string", "not_equals_string"], + } in actual + assert { + "attribute_name": "car_kind", + "attribute_type": "choice", + "allowed_ast_types": ["can_input", "is_empty", "is_not_empty", "has_choice", "not_has_choice"], + "choice_names": ["general_car", "emergency_vehicle", "construction_vehicle"], + } in actual From 5f8d9ca98a46363b5e8ea2fd13a288be40d34d2d Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 00:12:51 +0900 Subject: [PATCH 02/47] Use Pydantic model for restriction catalog --- annofabapi/util/attribute_restrictions.py | 62 +++++++++++++++++++---- tests/util/test_attribute_restrictions.py | 9 +++- 2 files changed, 58 insertions(+), 13 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index 33b30670..07067a60 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -30,10 +30,29 @@ from abc import ABC, abstractmethod from collections.abc import Collection from dataclasses import dataclass -from typing import Any +from typing import Any, Literal +from pydantic import BaseModel, ConfigDict from annofabapi.util.annotation_specs import AnnotationSpecsAccessor, get_choice, get_english_message +RestrictionAstType = Literal[ + "checked", + "unchecked", + "is_empty", + "is_not_empty", + "equals_string", + "not_equals_string", + "matches_string", + "not_matches_string", + "equals_integer", + "not_equals_integer", + "has_choice", + "not_has_choice", + "has_label", + "can_input", + "imply", +] + class Restriction(ABC): """属性の制約を表すクラス。""" @@ -484,7 +503,28 @@ def to_human_readable(self) -> str: return _ast_to_human_readable(self) -def get_attribute_restriction_catalog(annotation_specs: dict[str, Any]) -> list[dict[str, Any]]: +class AttributeRestrictionCatalogItem(BaseModel): + """ + LLMへ渡す属性制約カタログの1要素を表すモデル。 + + Args: + attribute_name: 属性名です。 + attribute_type: 属性種類です。 + allowed_ast_types: その属性で利用できるAST種別の一覧です。 + choice_names: 選択系属性で利用できる選択肢名の一覧です。 + label_names: リンク属性で利用できるラベル名の一覧です。 + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + attribute_name: str + attribute_type: str + allowed_ast_types: list[RestrictionAstType] + choice_names: list[str] | None = None + label_names: list[str] | None = None + + +def get_attribute_restriction_catalog(annotation_specs: dict[str, Any]) -> list[AttributeRestrictionCatalogItem]: """ 属性制約ASTを組み立てるための属性カタログを返します。 @@ -495,18 +535,18 @@ def get_attribute_restriction_catalog(annotation_specs: dict[str, Any]) -> list[ LLMへのプロンプトや入力候補生成に使いやすい属性カタログです。 """ accessor = AnnotationSpecsAccessor(annotation_specs) - catalog = [] + catalog: list[AttributeRestrictionCatalogItem] = [] for attribute in accessor.additionals: attribute_type = attribute["type"] - item: dict[str, Any] = { - "attribute_name": get_english_message(attribute["name"]), - "attribute_type": attribute_type, - "allowed_ast_types": _get_allowed_ast_types(attribute_type), - } + item = AttributeRestrictionCatalogItem( + attribute_name=get_english_message(attribute["name"]), + attribute_type=attribute_type, + allowed_ast_types=_get_allowed_ast_types(attribute_type), + ) if attribute_type in {"choice", "select"}: - item["choice_names"] = [get_english_message(choice["name"]) for choice in attribute["choices"]] + item = item.model_copy(update={"choice_names": [get_english_message(choice["name"]) for choice in attribute["choices"]]}) if attribute_type == "link": - item["label_names"] = [get_english_message(label["label_name"]) for label in accessor.labels] + item = item.model_copy(update={"label_names": [get_english_message(label["label_name"]) for label in accessor.labels]}) catalog.append(item) return catalog @@ -570,7 +610,7 @@ def _validate_restriction_ast(ast: RestrictionAst) -> None: raise ValueError("AST種別'can_input'の'enable'は真偽値である必要があります。") -def _get_allowed_ast_types(attribute_type: str) -> list[str]: +def _get_allowed_ast_types(attribute_type: str) -> list[RestrictionAstType]: if attribute_type == "flag": return ["can_input", "checked", "unchecked"] if attribute_type in {"text", "comment"}: diff --git a/tests/util/test_attribute_restrictions.py b/tests/util/test_attribute_restrictions.py index 8a5c1bec..f108947e 100644 --- a/tests/util/test_attribute_restrictions.py +++ b/tests/util/test_attribute_restrictions.py @@ -6,6 +6,7 @@ from annofabapi.util.annotation_specs import AnnotationSpecsAccessor from annofabapi.util.attribute_restrictions import ( AnnotationLink, + AttributeRestrictionCatalogItem, AttributeFactory, Checkbox, IntegerTextbox, @@ -424,14 +425,18 @@ class Test__get_attribute_restriction_catalog: def test__catalog(self): actual = get_attribute_restriction_catalog(accessor.annotation_specs) + assert all(isinstance(item, AttributeRestrictionCatalogItem) for item in actual) assert { "attribute_name": "tracking", "attribute_type": "tracking", "allowed_ast_types": ["can_input", "is_empty", "is_not_empty", "equals_string", "not_equals_string"], - } in actual + "choice_names": None, + "label_names": None, + } in [item.model_dump() for item in actual] assert { "attribute_name": "car_kind", "attribute_type": "choice", "allowed_ast_types": ["can_input", "is_empty", "is_not_empty", "has_choice", "not_has_choice"], "choice_names": ["general_car", "emergency_vehicle", "construction_vehicle"], - } in actual + "label_names": None, + } in [item.model_dump() for item in actual] From 697756bc0d2af875a4181b1956dfedb1be575639 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 00:14:11 +0900 Subject: [PATCH 03/47] Add schema descriptions to restriction catalog --- annofabapi/util/attribute_restrictions.py | 20 ++++++++++++++------ tests/util/test_attribute_restrictions.py | 10 ++++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index 07067a60..a2508146 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -32,7 +32,7 @@ from dataclasses import dataclass from typing import Any, Literal -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from annofabapi.util.annotation_specs import AnnotationSpecsAccessor, get_choice, get_english_message RestrictionAstType = Literal[ @@ -517,11 +517,19 @@ class AttributeRestrictionCatalogItem(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) - attribute_name: str - attribute_type: str - allowed_ast_types: list[RestrictionAstType] - choice_names: list[str] | None = None - label_names: list[str] | None = None + attribute_name: str = Field(description="Attribute name in annotation specs. LLM should refer to attributes by this name.") + attribute_type: str = Field(description="Attribute type in annotation specs, such as flag, text, integer, tracking, link, choice, or select.") + allowed_ast_types: list[RestrictionAstType] = Field( + description="Semantic AST node types that are allowed for this attribute. LLM must not use AST types outside this list." + ) + choice_names: list[str] | None = Field( + default=None, + description="Available choice names for choice/select attributes. Null for non-choice attributes.", + ) + label_names: list[str] | None = Field( + default=None, + description="Available label names for link attributes. Null for non-link attributes.", + ) def get_attribute_restriction_catalog(annotation_specs: dict[str, Any]) -> list[AttributeRestrictionCatalogItem]: diff --git a/tests/util/test_attribute_restrictions.py b/tests/util/test_attribute_restrictions.py index f108947e..eff3d4d8 100644 --- a/tests/util/test_attribute_restrictions.py +++ b/tests/util/test_attribute_restrictions.py @@ -440,3 +440,13 @@ def test__catalog(self): "choice_names": ["general_car", "emergency_vehicle", "construction_vehicle"], "label_names": None, } in [item.model_dump() for item in actual] + + def test__catalog_model_json_schema(self): + actual = AttributeRestrictionCatalogItem.model_json_schema() + + assert actual["properties"]["attribute_name"]["description"] == "Attribute name in annotation specs. LLM should refer to attributes by this name." + assert ( + actual["properties"]["allowed_ast_types"]["description"] + == "Semantic AST node types that are allowed for this attribute. LLM must not use AST types outside this list." + ) + assert actual["properties"]["choice_names"]["description"] == "Available choice names for choice/select attributes. Null for non-choice attributes." From 2ee7d93639ed50d47f136ed692a1ade98aa3e65f Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 00:21:27 +0900 Subject: [PATCH 04/47] =?UTF-8?q?=E5=B1=9E=E6=80=A7=E5=88=B6=E7=B4=84?= =?UTF-8?q?=E3=82=AB=E3=82=BF=E3=83=AD=E3=82=B0=E3=81=AE=E3=83=A2=E3=83=87?= =?UTF-8?q?=E3=83=AB=E3=81=AE=E8=AA=AC=E6=98=8E=E3=82=92=E6=97=A5=E6=9C=AC?= =?UTF-8?q?=E8=AA=9E=E3=81=AB=E7=BF=BB=E8=A8=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- annofabapi/util/attribute_restrictions.py | 258 +++++++++++++++++++++- tests/util/test_attribute_restrictions.py | 6 +- 2 files changed, 252 insertions(+), 12 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index a2508146..1b7b2645 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -53,6 +53,17 @@ "imply", ] +AttributeType = Literal[ + "flag", + "text", + "comment", + "integer", + "link", + "tracking", + "choice", + "select", +] + class Restriction(ABC): """属性の制約を表すクラス。""" @@ -517,18 +528,20 @@ class AttributeRestrictionCatalogItem(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) - attribute_name: str = Field(description="Attribute name in annotation specs. LLM should refer to attributes by this name.") - attribute_type: str = Field(description="Attribute type in annotation specs, such as flag, text, integer, tracking, link, choice, or select.") + attribute_name: str = Field(description="アノテーション仕様に定義された属性名です。LLMはこの名前を使って属性を参照します。") + attribute_type: AttributeType = Field( + description="アノテーション仕様上の属性種類です。例えば flag、text、integer、tracking、link、choice、select などです。" + ) allowed_ast_types: list[RestrictionAstType] = Field( - description="Semantic AST node types that are allowed for this attribute. LLM must not use AST types outside this list." + description="この属性で利用できる意味ベースAST種別の一覧です。LLMはこの一覧に含まれないAST種別を使ってはいけません。" ) choice_names: list[str] | None = Field( default=None, - description="Available choice names for choice/select attributes. Null for non-choice attributes.", + description="choice/select 属性で利用できる選択肢名の一覧です。それ以外の属性では null です。", ) label_names: list[str] | None = Field( default=None, - description="Available label names for link attributes. Null for non-link attributes.", + description="link 属性で利用できるラベル名の一覧です。それ以外の属性では null です。", ) @@ -546,26 +559,49 @@ def get_attribute_restriction_catalog(annotation_specs: dict[str, Any]) -> list[ catalog: list[AttributeRestrictionCatalogItem] = [] for attribute in accessor.additionals: attribute_type = attribute["type"] + choice_names = None + if attribute_type in {"choice", "select"}: + choice_names = [get_english_message(choice["name"]) for choice in attribute["choices"]] + label_names = None + if attribute_type == "link": + label_names = [get_english_message(label["label_name"]) for label in accessor.labels] item = AttributeRestrictionCatalogItem( attribute_name=get_english_message(attribute["name"]), attribute_type=attribute_type, allowed_ast_types=_get_allowed_ast_types(attribute_type), + choice_names=choice_names, + label_names=label_names, ) - if attribute_type in {"choice", "select"}: - item = item.model_copy(update={"choice_names": [get_english_message(choice["name"]) for choice in attribute["choices"]]}) - if attribute_type == "link": - item = item.model_copy(update={"label_names": [get_english_message(label["label_name"]) for label in accessor.labels]}) catalog.append(item) return catalog def _from_restriction_dict(obj: dict[str, Any], *, fac: AttributeFactory | None) -> Restriction: + """ + API向けの制約辞書から `Restriction` を復元します。 + + Args: + obj: APIの `restrictions` 要素を表す辞書です。 + fac: Noneでなければ、属性型に応じた妥当性検証に使う `AttributeFactory` です。 + + Returns: + 復元した `Restriction` オブジェクトです。 + """ attribute_id = obj["additional_data_definition_id"] condition = obj["condition"] return _from_condition_dict(attribute_id=attribute_id, condition=condition, fac=fac) def _validate_restriction_ast(ast: RestrictionAst) -> None: + """ + `RestrictionAst` の構造がAST種別に整合しているか検証します。 + + Args: + ast: 検証対象のASTです。 + + Raises: + ValueError: AST種別に対して必須フィールドが不足している場合、または型が不正な場合 + """ type_to_fields = { "checked": {"attribute_name"}, "unchecked": {"attribute_name"}, @@ -619,6 +655,18 @@ def _validate_restriction_ast(ast: RestrictionAst) -> None: def _get_allowed_ast_types(attribute_type: str) -> list[RestrictionAstType]: + """ + 属性種類ごとに利用可能なAST種別を返します。 + + Args: + attribute_type: アノテーション仕様上の属性種類です。 + + Returns: + 指定した属性種類で利用可能なAST種別の一覧です。 + + Raises: + ValueError: 未対応の属性種類が指定された場合 + """ if attribute_type == "flag": return ["can_input", "checked", "unchecked"] if attribute_type in {"text", "comment"}: @@ -635,6 +683,17 @@ def _get_allowed_ast_types(attribute_type: str) -> list[RestrictionAstType]: def _from_condition_dict(*, attribute_id: str, condition: dict[str, Any], fac: AttributeFactory | None) -> Restriction: + """ + 条件部分の辞書から `Restriction` を復元します。 + + Args: + attribute_id: 対象属性のIDです。 + condition: 条件部分のみを表す辞書です。 + fac: Noneでなければ、属性型に応じた妥当性検証に使う `AttributeFactory` です。 + + Returns: + 復元した `Restriction` オブジェクトです。 + """ condition_type = condition["_type"] if condition_type == "Imply": premise_restriction = _from_restriction_dict(condition["premise"], fac=fac) @@ -647,6 +706,19 @@ def _from_condition_dict(*, attribute_id: str, condition: dict[str, Any], fac: A def _from_condition_dict_without_validation(*, attribute_id: str, condition: dict[str, Any]) -> Restriction: + """ + 妥当性検証を行わずに条件辞書から `Restriction` を復元します。 + + Args: + attribute_id: 対象属性のIDです。 + condition: 条件部分のみを表す辞書です。 + + Returns: + 復元した `Restriction` オブジェクトです。 + + Raises: + ValueError: 未知の制約種別が指定された場合 + """ condition_type = condition["_type"] if condition_type == "CanInput": return CanInput(attribute_id, enable=condition["enable"]) @@ -664,6 +736,20 @@ def _from_condition_dict_without_validation(*, attribute_id: str, condition: dic def _from_condition_dict_with_validation(*, attribute_id: str, condition: dict[str, Any], fac: AttributeFactory) -> Restriction: + """ + 属性型の妥当性を検証しながら条件辞書から `Restriction` を復元します。 + + Args: + attribute_id: 対象属性のIDです。 + condition: 条件部分のみを表す辞書です。 + fac: 属性生成と妥当性検証に使う `AttributeFactory` です。 + + Returns: + 復元した `Restriction` オブジェクトです。 + + Raises: + ValueError: 属性型に対して許可されていない制約が指定された場合 + """ attribute = fac.accessor.get_attribute(attribute_id=attribute_id) attribute_obj = _create_attribute_object(fac, attribute) attribute_type = attribute["type"] @@ -736,6 +822,19 @@ def _from_condition_dict_with_validation(*, attribute_id: str, condition: dict[s def _create_attribute_object(fac: AttributeFactory, attribute: dict[str, Any]) -> Attribute: + """ + 属性定義から対応する高水準属性オブジェクトを生成します。 + + Args: + fac: 属性生成に使う `AttributeFactory` です。 + attribute: アノテーション仕様上の属性定義です。 + + Returns: + 対応する高水準属性オブジェクトです。 + + Raises: + ValueError: 未対応の属性種類が指定された場合 + """ attribute_id = attribute["additional_data_definition_id"] attribute_type = attribute["type"] if attribute_type == "flag": @@ -754,11 +853,34 @@ def _create_attribute_object(fac: AttributeFactory, attribute: dict[str, Any]) - def _create_attribute_object_with_name(fac: AttributeFactory, attribute_name: str) -> Attribute: + """ + 属性名から対応する高水準属性オブジェクトを生成します。 + + Args: + fac: 属性生成に使う `AttributeFactory` です。 + attribute_name: 属性名です。 + + Returns: + 対応する高水準属性オブジェクトです。 + """ attribute = fac.accessor.get_attribute(attribute_name=attribute_name) return _create_attribute_object(fac, attribute) def _ast_to_restriction(ast: RestrictionAst, *, fac: AttributeFactory) -> Restriction: + """ + 意味ベースのASTを `Restriction` オブジェクトへコンパイルします。 + + Args: + ast: 変換元のASTです。 + fac: 属性生成と妥当性検証に使う `AttributeFactory` です。 + + Returns: + 変換後の `Restriction` オブジェクトです。 + + Raises: + ValueError: AST種別が未知の場合、または属性型に対して利用できないASTが指定された場合 + """ if ast.type == "imply": assert ast.premise is not None assert ast.conclusion is not None @@ -826,6 +948,19 @@ def _ast_to_restriction(ast: RestrictionAst, *, fac: AttributeFactory) -> Restri def _attribute_with_empty_check(fac: AttributeFactory, attribute_name: str) -> EmptyCheckMixin: + """ + 空判定をサポートする属性オブジェクトを取得します。 + + Args: + fac: 属性生成に使う `AttributeFactory` です。 + attribute_name: 属性名です。 + + Returns: + `is_empty()` / `is_not_empty()` を持つ属性オブジェクトです。 + + Raises: + ValueError: 指定した属性で空判定を利用できない場合 + """ attribute_obj = _create_attribute_object_with_name(fac, attribute_name) if not isinstance(attribute_obj, EmptyCheckMixin): attribute = fac.accessor.get_attribute(attribute_name=attribute_name) @@ -838,11 +973,35 @@ def _attribute_with_empty_check(fac: AttributeFactory, attribute_name: str) -> E def _raise_invalid_ast(*, attribute: dict[str, Any], ast: RestrictionAst) -> None: + """ + 属性型に対して不正なAST種別が指定されたことを表す例外を送出します。 + + Args: + attribute: アノテーション仕様上の属性定義です。 + ast: 不正だったASTです。 + + Raises: + ValueError: 常に送出されます。 + """ attribute_name = get_english_message(attribute["name"]) raise ValueError(f"属性'{attribute_name}'(type='{attribute['type']}')ではAST種別'{ast.type}'を利用できません。") def _parse_integer_value(value: str, *, attribute: dict[str, Any], condition: dict[str, Any]) -> int: + """ + 整数属性向けの文字列値を整数へ変換します。 + + Args: + value: 変換対象の文字列値です。 + attribute: アノテーション仕様上の属性定義です。 + condition: 元の条件辞書です。 + + Returns: + 変換後の整数値です。 + + Raises: + ValueError: 整数へ変換できない場合 + """ try: return int(value) except ValueError as exc: @@ -851,6 +1010,17 @@ def _parse_integer_value(value: str, *, attribute: dict[str, Any], condition: di def _raise_invalid_restriction(*, attribute: dict[str, Any], condition: dict[str, Any], detail: str | None = None) -> None: + """ + 属性型に対して不正な制約が指定されたことを表す例外を送出します。 + + Args: + attribute: アノテーション仕様上の属性定義です。 + condition: 不正だった制約条件です。 + detail: 補足メッセージです。 + + Raises: + ValueError: 常に送出されます。 + """ attribute_name = get_english_message(attribute["name"]) message = f"属性'{attribute_name}'(type='{attribute['type']}')では制約'{condition['_type']}'を利用できません。" if detail is not None: @@ -859,6 +1029,19 @@ def _raise_invalid_restriction(*, attribute: dict[str, Any], condition: dict[str def _restriction_to_ast(restriction: Restriction, *, accessor: AnnotationSpecsAccessor) -> RestrictionAst: + """ + `Restriction` を意味ベースの `RestrictionAst` へ変換します。 + + Args: + restriction: 変換元の `Restriction` です。 + accessor: 属性名や選択肢名の解決に使う `AnnotationSpecsAccessor` です。 + + Returns: + 変換後の `RestrictionAst` です。 + + Raises: + ValueError: ASTへ変換できない制約が含まれている場合 + """ if isinstance(restriction, Imply): return RestrictionAst( type="imply", @@ -913,6 +1096,20 @@ def _restriction_to_ast(restriction: Restriction, *, accessor: AnnotationSpecsAc def _restriction_to_python_expr(restriction: Restriction, *, accessor: AnnotationSpecsAccessor, factory_name: str) -> str: + """ + `Restriction` を fluent API 形式の Python 式へ変換します。 + + Args: + restriction: 変換元の `Restriction` です。 + accessor: 属性名や選択肢名の解決に使う `AnnotationSpecsAccessor` です。 + factory_name: `AttributeFactory` の変数名です。 + + Returns: + 変換後の Python 式です。 + + Raises: + ValueError: Python 式へ変換できない制約が含まれている場合 + """ if isinstance(restriction, Imply): premise_expr = _restriction_to_python_expr(restriction.premise_restriction, accessor=accessor, factory_name=factory_name) conclusion_expr = _restriction_to_python_expr(restriction.conclusion_restriction, accessor=accessor, factory_name=factory_name) @@ -987,6 +1184,19 @@ def _restriction_to_python_expr(restriction: Restriction, *, accessor: Annotatio def _attribute_to_python_expr(attribute: dict[str, Any], *, factory_name: str) -> str: + """ + 属性定義を `AttributeFactory` 呼び出しの Python 式へ変換します。 + + Args: + attribute: アノテーション仕様上の属性定義です。 + factory_name: `AttributeFactory` の変数名です。 + + Returns: + 属性生成部分の Python 式です。 + + Raises: + ValueError: 未対応の属性種類が指定された場合 + """ attribute_name = get_english_message(attribute["name"]) attribute_type = attribute["type"] @@ -1009,6 +1219,18 @@ def _attribute_to_python_expr(attribute: dict[str, Any], *, factory_name: str) - def _ast_to_human_readable(ast: RestrictionAst) -> str: + """ + ASTを人間向けの読みやすい文字列表現へ変換します。 + + Args: + ast: 変換元のASTです。 + + Returns: + 人間向けの文字列表現です。 + + Raises: + ValueError: 未知のAST種別が指定された場合 + """ if ast.type == "imply": assert ast.premise is not None assert ast.conclusion is not None @@ -1052,8 +1274,26 @@ def _ast_to_human_readable(ast: RestrictionAst) -> str: def _repr_python_value(value: Any) -> str: + """ + Python 式へ埋め込む値を `repr()` で文字列化します。 + + Args: + value: 文字列化する値です。 + + Returns: + `repr()` による文字列表現です。 + """ return repr(value) def _quote_human(value: Any) -> str: + """ + 人間向け表示用に値をシングルクォートで囲みます。 + + Args: + value: 表示対象の値です。 + + Returns: + シングルクォートで囲んだ文字列表現です。 + """ return f"'{value}'" diff --git a/tests/util/test_attribute_restrictions.py b/tests/util/test_attribute_restrictions.py index eff3d4d8..9e3715e1 100644 --- a/tests/util/test_attribute_restrictions.py +++ b/tests/util/test_attribute_restrictions.py @@ -444,9 +444,9 @@ def test__catalog(self): def test__catalog_model_json_schema(self): actual = AttributeRestrictionCatalogItem.model_json_schema() - assert actual["properties"]["attribute_name"]["description"] == "Attribute name in annotation specs. LLM should refer to attributes by this name." + assert actual["properties"]["attribute_name"]["description"] == "アノテーション仕様に定義された属性名です。LLMはこの名前を使って属性を参照します。" assert ( actual["properties"]["allowed_ast_types"]["description"] - == "Semantic AST node types that are allowed for this attribute. LLM must not use AST types outside this list." + == "この属性で利用できる意味ベースAST種別の一覧です。LLMはこの一覧に含まれないAST種別を使ってはいけません。" ) - assert actual["properties"]["choice_names"]["description"] == "Available choice names for choice/select attributes. Null for non-choice attributes." + assert actual["properties"]["choice_names"]["description"] == "choice/select 属性で利用できる選択肢名の一覧です。それ以外の属性では null です。" From 0056e4a495f74b4d6f3b715703e17bd56b4df194 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 00:24:03 +0900 Subject: [PATCH 05/47] =?UTF-8?q?=E5=B1=9E=E6=80=A7=E5=88=B6=E7=B4=84?= =?UTF-8?q?=E3=82=AB=E3=82=BF=E3=83=AD=E3=82=B0=E3=81=AE=E5=B1=9E=E6=80=A7?= =?UTF-8?q?=E3=82=BF=E3=82=A4=E3=83=97=E3=82=92=E8=BF=BD=E5=8A=A0=E3=83=87?= =?UTF-8?q?=E3=83=BC=E3=82=BF=E5=AE=9A=E7=BE=A9=E3=82=BF=E3=82=A4=E3=83=97?= =?UTF-8?q?=E3=81=AB=E5=A4=89=E6=9B=B4=E3=81=97=E3=80=81=E4=B8=8D=E8=A6=81?= =?UTF-8?q?=E3=81=AA=E5=B1=9E=E6=80=A7=E3=82=BF=E3=82=A4=E3=83=97=E3=82=92?= =?UTF-8?q?=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- annofabapi/util/attribute_restrictions.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index 1b7b2645..e9fd8c4d 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -33,6 +33,7 @@ from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field +from annofabapi.pydantic_models.additional_data_definition_type import AdditionalDataDefinitionType from annofabapi.util.annotation_specs import AnnotationSpecsAccessor, get_choice, get_english_message RestrictionAstType = Literal[ @@ -53,18 +54,6 @@ "imply", ] -AttributeType = Literal[ - "flag", - "text", - "comment", - "integer", - "link", - "tracking", - "choice", - "select", -] - - class Restriction(ABC): """属性の制約を表すクラス。""" @@ -529,7 +518,7 @@ class AttributeRestrictionCatalogItem(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) attribute_name: str = Field(description="アノテーション仕様に定義された属性名です。LLMはこの名前を使って属性を参照します。") - attribute_type: AttributeType = Field( + attribute_type: AdditionalDataDefinitionType = Field( description="アノテーション仕様上の属性種類です。例えば flag、text、integer、tracking、link、choice、select などです。" ) allowed_ast_types: list[RestrictionAstType] = Field( From 59d11436703e6a2121a4d60e06cc0d04aaaa2cdb Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 00:26:03 +0900 Subject: [PATCH 06/47] =?UTF-8?q?=E6=96=B0=E3=81=97=E3=81=84SKILL.md?= =?UTF-8?q?=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E3=82=92=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=E3=81=97=E3=80=81Python=E3=82=B3=E3=83=BC=E3=83=87=E3=82=A3?= =?UTF-8?q?=E3=83=B3=E3=82=B0=E3=82=B9=E3=82=BF=E3=82=A4=E3=83=AB=E3=81=AB?= =?UTF-8?q?=E9=96=A2=E3=81=99=E3=82=8B=E3=82=AC=E3=82=A4=E3=83=89=E3=83=A9?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=82=92=E8=A8=98=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/python-coding-style/SKILL.md | 14 ++++++++++++ .agents/skills/test-writing/SKILL.md | 25 +++++++++++++++++++++ AGENTS.md | 13 +++++++++++ 3 files changed, 52 insertions(+) create mode 100644 .agents/skills/python-coding-style/SKILL.md create mode 100644 .agents/skills/test-writing/SKILL.md create mode 100644 AGENTS.md diff --git a/.agents/skills/python-coding-style/SKILL.md b/.agents/skills/python-coding-style/SKILL.md new file mode 100644 index 00000000..a9472a87 --- /dev/null +++ b/.agents/skills/python-coding-style/SKILL.md @@ -0,0 +1,14 @@ +--- +name: python-coding-style +description: Pythonコードを作成・修正するときに使用。 +--- + +# 全般 +* できるだけ型ヒントを付ける。 + * できるだけ汎用的な型ヒントをつける。たとえばlistでもsetでも良いならば、`Collection`や`Iterable`を使う。 + * 特に理由がない限り、`object`や`Any`は避ける。 +* docstring は Google スタイルで記述する。 +* ログメッセージやコメントは日本語で記述する +* 戻り値をtupleで返そうとする場合は、`NamedTuple`, `dataclass`, pydantic modelの使用を検討して、本当にtupleが適切かどうかを判断する。 +* モジュールレベルの定数、クラス属性、インスタンス属性などには直後に docstring として記述する。VSCodeのtooltipに表示させるため。 + diff --git a/.agents/skills/test-writing/SKILL.md b/.agents/skills/test-writing/SKILL.md new file mode 100644 index 00000000..f2fc2c02 --- /dev/null +++ b/.agents/skills/test-writing/SKILL.md @@ -0,0 +1,25 @@ +--- +name: test-writing +description: Pythonのテストコードを作成・修正するときに使用。 +--- + +## テスト方針 +* `DummyApi`、`DummyService`、`FakeClient` のような自前の疑似実装は原則追加しない。 +* `monkeypatch` や `mock` は、外部API、ネットワーク、時刻、環境変数、ファイルシステム外の副作用など、テストで制御しないと不安定になる境界に限定して使用する。 +* 関数呼び出しの詳細に強く結びついたテストは、原則として追加しないでください。 +* `mock.assert_called_once_with(...)` のような配線確認だけのテストは、明確な回帰防止上の理由がある場合に限って追加してください。 +* テストを書くときは「そのテストが将来の設計変更を不必要に妨げないか」を確認してください。 +* ただし、外部API連携、ジョブ投入、通知送信、永続化など、副作用の境界にある処理では、呼び出し確認テストを許可します。 +* `argparse`などのCLIパーサーで制御している部分はテストを作成しないでください。たとえば必須引数が指定されない場合のエラーなどです。これはライブラリの責務だからです。 + + +## テストの実行方法 +* `uv run pytest` を使う。 + +## テストコードの具体的なスタイル +* テストファイルのディレクトリ構成やファイル名は、`annofabcli/`配下のプロダクションコードと対応させる。 +* 例外確認には `pytest.raises` を使用する。 +* `pytest.raises` ではエラーメッセージ文字列の確認は行わない。 +* 一時ディレクトリが必要な場合は `tmp_path` fixture を使う。 +* WebAPI にアクセスするテストには `pytest.mark.access_webapi` を付与する。CLI テストは、引数上はローカル処理に見えても共通ログイン処理を通る場合があるので注意する。 + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..5654b59c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,13 @@ + +## 想定する実行環境 +* AIによる指摘や応答は日本語で書く。 + +## コードの修正方針 +* 原則、破壊的変更を行って修正してください。コードをシンプルにするためです。 + +## Coding Agent による作業の進め方 +1. コードを修正する。関連するテストコードやドキュメントも修正する。 +2. 自分自身でレビューする +3. `make format`, `make lint`を実行する。 +4. 関連するテストコードを実行する。 +5. Git にコミットする。ただしmainブランチで作業している場合は、pull requestを作成する。 From c799ae61fc120bd7a8fe84743ae85236e06d2e45 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 00:26:19 +0900 Subject: [PATCH 07/47] =?UTF-8?q?config.toml=E3=82=92=E6=96=B0=E8=A6=8F?= =?UTF-8?q?=E4=BD=9C=E6=88=90=E3=81=97=E3=80=81=E6=89=BF=E8=AA=8D=E3=83=9D?= =?UTF-8?q?=E3=83=AA=E3=82=B7=E3=83=BC=E3=82=84=E3=82=B5=E3=83=B3=E3=83=89?= =?UTF-8?q?=E3=83=9C=E3=83=83=E3=82=AF=E3=82=B9=E8=A8=AD=E5=AE=9A=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codex/config.toml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .codex/config.toml diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 00000000..68a3201d --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,24 @@ +# 承認ポリシー。 +# "never" は、Codex がコマンド実行や権限昇格のたびに確認せず進む設定です。 +approval_policy = "never" + +# sandbox の種類。 +# "workspace-write" は、workspace 内と許可された追加パスへの書き込みを許可します。 +sandbox_mode = "workspace-write" + +[shell_environment_policy.set] +# uv のキャッシュ先を /tmp 配下に固定し、ユーザー名依存のパスを避けます。 +UV_CACHE_DIR = "/tmp/uv-cache" + +[sandbox_workspace_write] +# workspace-write sandbox 内で外向きネットワーク通信を許可します。 +network_access = true + +# workspace に加えて書き込みを許可する追加ディレクトリ一覧です。 +# 今回は uv キャッシュと一時ファイル用途を想定して /tmp を許可しています。 +writable_roots = [ + "/tmp", +] + +[features] +codex_hooks = true From bafc2d5c0e43b2debfe43822f4ff9e80f27149e1 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 00:27:58 +0900 Subject: [PATCH 08/47] =?UTF-8?q?copilot-instructions.md=E3=81=8B=E3=82=89?= =?UTF-8?q?=E3=83=97=E3=83=AD=E3=82=B8=E3=82=A7=E3=82=AF=E3=83=88=E3=81=AE?= =?UTF-8?q?=E7=9B=AE=E7=9A=84=E3=80=81=E9=96=8B=E7=99=BA=E3=82=B3=E3=83=9E?= =?UTF-8?q?=E3=83=B3=E3=83=89=E3=80=81=E6=8A=80=E8=A1=93=E3=82=B9=E3=82=BF?= =?UTF-8?q?=E3=83=83=E3=82=AF=E3=80=81=E3=83=87=E3=82=A3=E3=83=AC=E3=82=AF?= =?UTF-8?q?=E3=83=88=E3=83=AA=E6=A7=8B=E9=80=A0=E3=80=81=E3=82=B3=E3=83=BC?= =?UTF-8?q?=E3=83=87=E3=82=A3=E3=83=B3=E3=82=B0=E3=82=B9=E3=82=BF=E3=82=A4?= =?UTF-8?q?=E3=83=AB=E3=80=81=E3=83=86=E3=82=B9=E3=83=88=E3=82=B3=E3=83=BC?= =?UTF-8?q?=E3=83=89=E3=80=81=E4=BD=9C=E6=A5=AD=E3=81=AE=E9=80=B2=E3=82=81?= =?UTF-8?q?=E6=96=B9=E3=80=81=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E3=81=AB?= =?UTF-8?q?=E9=96=A2=E3=81=99=E3=82=8B=E3=82=BB=E3=82=AF=E3=82=B7=E3=83=A7?= =?UTF-8?q?=E3=83=B3=E3=82=92=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/python-coding-style/SKILL.md | 1 + .github/copilot-instructions.md | 39 +-------------------- 2 files changed, 2 insertions(+), 38 deletions(-) diff --git a/.agents/skills/python-coding-style/SKILL.md b/.agents/skills/python-coding-style/SKILL.md index a9472a87..da0a3c55 100644 --- a/.agents/skills/python-coding-style/SKILL.md +++ b/.agents/skills/python-coding-style/SKILL.md @@ -11,4 +11,5 @@ description: Pythonコードを作成・修正するときに使用。 * ログメッセージやコメントは日本語で記述する * 戻り値をtupleで返そうとする場合は、`NamedTuple`, `dataclass`, pydantic modelの使用を検討して、本当にtupleが適切かどうかを判断する。 * モジュールレベルの定数、クラス属性、インスタンス属性などには直後に docstring として記述する。VSCodeのtooltipに表示させるため。 +* dictから値を取得する際、必須なキーならばブラケット記法を使う。キーが必須がどうか分からない場合は、必須とみなす。 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4d08d16e..6503337a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,40 +1,3 @@ # copilot-instructions.md -## プロジェクトの目的 -AnnofabのWebAPIのPythonクライアントライブラリです。 - -## 開発でよく使うコマンド -* コードのフォーマット: `make format` -* Lintの実行: `make lint` -* テストの実行: `make test` -* ドキュメントの実行: `make docs` - -## 技術スタック -* Python 3.9 以上 -* テストフレームワーク: Pytest v8 以上 - -## ディレクトリ構造概要 - -* `annofabcli/**`: アプリケーションのソースコード -* `tests/**`: テストコード - * `tests/data/**`: テストコードが参照するリソース -* `docs/*`: ドキュメント - -## コーディングスタイル - -### Python -* dictから値を取得する際、必須なキーならばブラケット記法を使う。キーが必須がどうか分からない場合は、必須とみなす。 -* できるだけ`os.path`でなく`pathlib.Path`を使う(Lint`flake8-use-pathlib`に従う) -* Noneの判定、空文字列の判定、長さが0のコレクションの判定は、falsyとして判定するのでなく、`if a is not None:`のように判定内容を明記してください。 -* 型ヒントを`dict`にする場合、`dict[str, Any]`のようにキーと値の型を指定する。 - -### テストコード -* Errorの確認は、`pytest.raises`を使用する。エラーメッセージの確認は行わない。 -* 一時ディレクトリを使用する場合は、`tmp_path` fixtureを利用する。 - - -## 作業の進め方 -* コードの修正が完了したら`make format`を実行してフォーマットを行い、その後`make lint`を実行してLintエラーがないことを確認する。 - -## レビュー -* PRレビューの際は、日本語でレビューを行う +`AGENTS.md`や`.agents/skills/`以下のファイルを参照してください。 From 5ca1adf0f10bd06ec042d0f1bba8236db47dcf16 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 00:30:33 +0900 Subject: [PATCH 09/47] =?UTF-8?q?RestrictionAst=E3=81=AEtype=E3=83=95?= =?UTF-8?q?=E3=82=A3=E3=83=BC=E3=83=AB=E3=83=89=E3=82=92str=E3=81=8B?= =?UTF-8?q?=E3=82=89RestrictionAstType=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- annofabapi/util/attribute_restrictions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index e9fd8c4d..09082c5f 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -420,7 +420,7 @@ class RestrictionAst: conclusion: `imply` ノードの結論です。 """ - type: str + type: RestrictionAstType attribute_name: str | None = None value: str | int | None = None choice_name: str | None = None From 00420536731caa9e82095905dbaeb6f59d98664c Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 00:40:28 +0900 Subject: [PATCH 10/47] Convert RestrictionAst to pydantic model --- annofabapi/util/attribute_restrictions.py | 76 ++++++++--------------- tests/util/test_attribute_restrictions.py | 22 +++++-- 2 files changed, 45 insertions(+), 53 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index 09082c5f..d29961ca 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -29,10 +29,10 @@ from abc import ABC, abstractmethod from collections.abc import Collection -from dataclasses import dataclass from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator + from annofabapi.pydantic_models.additional_data_definition_type import AdditionalDataDefinitionType from annofabapi.util.annotation_specs import AnnotationSpecsAccessor, get_choice, get_english_message @@ -54,6 +54,7 @@ "imply", ] + class Restriction(ABC): """属性の制約を表すクラス。""" @@ -401,8 +402,7 @@ def selection(self, *, attribute_id: str | None = None, attribute_name: str | No return Selection(self.accessor, attribute_id=attribute_id, attribute_name=attribute_name) -@dataclass(frozen=True) -class RestrictionAst: +class RestrictionAst(BaseModel): """ LLMやCLI向けの意味ベースな属性制約ASTを表すクラス。 @@ -420,17 +420,21 @@ class RestrictionAst: conclusion: `imply` ノードの結論です。 """ - type: RestrictionAstType - attribute_name: str | None = None - value: str | int | None = None - choice_name: str | None = None - enable: bool | None = None - label_names: list[str] | None = None - premise: "RestrictionAst | None" = None - conclusion: "RestrictionAst | None" = None + model_config = ConfigDict(extra="forbid", frozen=True) - def __post_init__(self) -> None: + type: RestrictionAstType = Field(description="ASTノードの種類です。") + attribute_name: str | None = Field(default=None, description="対象属性の名前です。") + value: str | int | None = Field(default=None, description="文字列や整数の比較値です。") + choice_name: str | None = Field(default=None, description="選択系属性で利用する選択肢名です。") + enable: bool | None = Field(default=None, description="`can_input` ノードで使う真偽値です。") + label_names: list[str] | None = Field(default=None, description="`has_label` ノードで使うラベル名の一覧です。") + premise: "RestrictionAst | None" = Field(default=None, description="`imply` ノードの前提です。") + conclusion: "RestrictionAst | None" = Field(default=None, description="`imply` ノードの結論です。") + + @model_validator(mode="after") + def validate_restriction_ast(self) -> "RestrictionAst": _validate_restriction_ast(self) + return self def to_dict(self) -> dict[str, Any]: """ @@ -439,22 +443,7 @@ def to_dict(self) -> dict[str, Any]: Returns: 辞書形式のASTです。 """ - result: dict[str, Any] = {"type": self.type} - if self.attribute_name is not None: - result["attribute_name"] = self.attribute_name - if self.value is not None: - result["value"] = self.value - if self.choice_name is not None: - result["choice_name"] = self.choice_name - if self.enable is not None: - result["enable"] = self.enable - if self.label_names is not None: - result["label_names"] = self.label_names - if self.premise is not None: - result["premise"] = self.premise.to_dict() - if self.conclusion is not None: - result["conclusion"] = self.conclusion.to_dict() - return result + return self.model_dump(mode="python", exclude_none=True) @classmethod def from_dict(cls, obj: dict[str, Any]) -> "RestrictionAst": @@ -467,18 +456,7 @@ def from_dict(cls, obj: dict[str, Any]) -> "RestrictionAst": Returns: 復元した `RestrictionAst` です。 """ - premise = cls.from_dict(obj["premise"]) if obj.get("premise") is not None else None - conclusion = cls.from_dict(obj["conclusion"]) if obj.get("conclusion") is not None else None - return cls( - type=obj["type"], - attribute_name=obj.get("attribute_name"), - value=obj.get("value"), - choice_name=obj.get("choice_name"), - enable=obj.get("enable"), - label_names=obj.get("label_names"), - premise=premise, - conclusion=conclusion, - ) + return cls.model_validate(obj) def to_restriction(self, annotation_specs: dict[str, Any]) -> Restriction: """ @@ -503,6 +481,9 @@ def to_human_readable(self) -> str: return _ast_to_human_readable(self) +RestrictionAst.model_rebuild() + + class AttributeRestrictionCatalogItem(BaseModel): """ LLMへ渡す属性制約カタログの1要素を表すモデル。 @@ -626,9 +607,7 @@ def _validate_restriction_ast(ast: RestrictionAst) -> None: if value is not None } if actual_fields != required_fields: - raise ValueError( - f"AST種別'{ast.type}'のフィールドが不正です。 :: required={sorted(required_fields)}, actual={sorted(actual_fields)}" - ) + raise ValueError(f"AST種別'{ast.type}'のフィールドが不正です。 :: required={sorted(required_fields)}, actual={sorted(actual_fields)}") if ast.type in {"equals_string", "not_equals_string", "matches_string", "not_matches_string"} and not isinstance(ast.value, str): raise ValueError(f"AST種別'{ast.type}'の'value'は文字列である必要があります。") @@ -636,9 +615,8 @@ def _validate_restriction_ast(ast: RestrictionAst) -> None: raise ValueError(f"AST種別'{ast.type}'の'value'は整数である必要があります。") if ast.type in {"has_choice", "not_has_choice"} and not isinstance(ast.choice_name, str): raise ValueError(f"AST種別'{ast.type}'の'choice_name'は文字列である必要があります。") - if ast.type == "has_label": - if not isinstance(ast.label_names, list) or any(not isinstance(label_name, str) for label_name in ast.label_names): - raise ValueError("AST種別'has_label'の'label_names'は文字列のリストである必要があります。") + if ast.type == "has_label" and (not isinstance(ast.label_names, list) or any(not isinstance(label_name, str) for label_name in ast.label_names)): + raise ValueError("AST種別'has_label'の'label_names'は文字列のリストである必要があります。") if ast.type == "can_input" and not isinstance(ast.enable, bool): raise ValueError("AST種別'can_input'の'enable'は真偽値である必要があります。") @@ -1262,7 +1240,7 @@ def _ast_to_human_readable(ast: RestrictionAst) -> str: raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") -def _repr_python_value(value: Any) -> str: +def _repr_python_value(value: object) -> str: """ Python 式へ埋め込む値を `repr()` で文字列化します。 @@ -1275,7 +1253,7 @@ def _repr_python_value(value: Any) -> str: return repr(value) -def _quote_human(value: Any) -> str: +def _quote_human(value: object) -> str: """ 人間向け表示用に値をシングルクォートで囲みます。 diff --git a/tests/util/test_attribute_restrictions.py b/tests/util/test_attribute_restrictions.py index 9e3715e1..afcad119 100644 --- a/tests/util/test_attribute_restrictions.py +++ b/tests/util/test_attribute_restrictions.py @@ -2,12 +2,13 @@ from pathlib import Path import pytest +from pydantic import ValidationError from annofabapi.util.annotation_specs import AnnotationSpecsAccessor from annofabapi.util.attribute_restrictions import ( AnnotationLink, - AttributeRestrictionCatalogItem, AttributeFactory, + AttributeRestrictionCatalogItem, Checkbox, IntegerTextbox, Restriction, @@ -417,9 +418,17 @@ def test__to_human_readable(self): assert actual == "'link_car' HAS LABEL 'car', 'number_plate'" def test__invalid_fields(self): - with pytest.raises(ValueError, match="required=.*attribute_name.*value"): + with pytest.raises(ValidationError): RestrictionAst(type="equals_string", attribute_name="note") + def test__model_json_schema(self): + actual = RestrictionAst.model_json_schema() + properties = actual["$defs"]["RestrictionAst"]["properties"] + + assert properties["type"]["description"] == "ASTノードの種類です。" + assert properties["attribute_name"]["description"] == "対象属性の名前です。" + assert properties["premise"]["description"] == "`imply` ノードの前提です。" + class Test__get_attribute_restriction_catalog: def test__catalog(self): @@ -444,9 +453,14 @@ def test__catalog(self): def test__catalog_model_json_schema(self): actual = AttributeRestrictionCatalogItem.model_json_schema() - assert actual["properties"]["attribute_name"]["description"] == "アノテーション仕様に定義された属性名です。LLMはこの名前を使って属性を参照します。" + assert ( + actual["properties"]["attribute_name"]["description"] + == "アノテーション仕様に定義された属性名です。LLMはこの名前を使って属性を参照します。" + ) assert ( actual["properties"]["allowed_ast_types"]["description"] == "この属性で利用できる意味ベースAST種別の一覧です。LLMはこの一覧に含まれないAST種別を使ってはいけません。" ) - assert actual["properties"]["choice_names"]["description"] == "choice/select 属性で利用できる選択肢名の一覧です。それ以外の属性では null です。" + assert ( + actual["properties"]["choice_names"]["description"] == "choice/select 属性で利用できる選択肢名の一覧です。それ以外の属性では null です。" + ) From b77d5a9a085964c2a08584de84b55a1284b7ff96 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 00:41:31 +0900 Subject: [PATCH 11/47] Remove redundant RestrictionAst dict helpers --- annofabapi/util/attribute_restrictions.py | 22 ---------------------- tests/util/test_attribute_restrictions.py | 8 ++++---- 2 files changed, 4 insertions(+), 26 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index d29961ca..1cf1af96 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -436,28 +436,6 @@ def validate_restriction_ast(self) -> "RestrictionAst": _validate_restriction_ast(self) return self - def to_dict(self) -> dict[str, Any]: - """ - ASTをJSONシリアライズしやすい辞書へ変換します。 - - Returns: - 辞書形式のASTです。 - """ - return self.model_dump(mode="python", exclude_none=True) - - @classmethod - def from_dict(cls, obj: dict[str, Any]) -> "RestrictionAst": - """ - 辞書からASTを復元します。 - - Args: - obj: ASTを表す辞書です。 - - Returns: - 復元した `RestrictionAst` です。 - """ - return cls.model_validate(obj) - def to_restriction(self, annotation_specs: dict[str, Any]) -> Restriction: """ ASTをRestrictionオブジェクトへコンパイルします。 diff --git a/tests/util/test_attribute_restrictions.py b/tests/util/test_attribute_restrictions.py index afcad119..9ba639af 100644 --- a/tests/util/test_attribute_restrictions.py +++ b/tests/util/test_attribute_restrictions.py @@ -366,21 +366,21 @@ def test__from_ast(self): class Test__RestrictionAst: - def test__to_dict(self): + def test__model_dump(self): ast = RestrictionAst( type="imply", premise=RestrictionAst(type="checked", attribute_name="occluded"), conclusion=RestrictionAst(type="is_not_empty", attribute_name="note"), ) - assert ast.to_dict() == { + assert ast.model_dump(mode="python", exclude_none=True) == { "type": "imply", "premise": {"type": "checked", "attribute_name": "occluded"}, "conclusion": {"type": "is_not_empty", "attribute_name": "note"}, } - def test__from_dict(self): - actual = RestrictionAst.from_dict( + def test__model_validate(self): + actual = RestrictionAst.model_validate( { "type": "imply", "premise": {"type": "checked", "attribute_name": "occluded"}, From 9fa5e6e30c6ada00eb64c78b5f0f4d0d9802b17c Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 00:52:49 +0900 Subject: [PATCH 12/47] =?UTF-8?q?SKILL.md=E3=81=ABmatch=E6=96=87=E3=81=AE?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E3=82=92=E6=8E=A8=E5=A5=A8=E3=81=99=E3=82=8B?= =?UTF-8?q?=E9=A0=85=E7=9B=AE=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- .agents/skills/python-coding-style/SKILL.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.agents/skills/python-coding-style/SKILL.md b/.agents/skills/python-coding-style/SKILL.md index da0a3c55..c97856cc 100644 --- a/.agents/skills/python-coding-style/SKILL.md +++ b/.agents/skills/python-coding-style/SKILL.md @@ -12,4 +12,5 @@ description: Pythonコードを作成・修正するときに使用。 * 戻り値をtupleで返そうとする場合は、`NamedTuple`, `dataclass`, pydantic modelの使用を検討して、本当にtupleが適切かどうかを判断する。 * モジュールレベルの定数、クラス属性、インスタンス属性などには直後に docstring として記述する。VSCodeのtooltipに表示させるため。 * dictから値を取得する際、必須なキーならばブラケット記法を使う。キーが必須がどうか分からない場合は、必須とみなす。 +* match文が利用できる箇所では、if文よりもmatch文を使用する。 From 859e88668d74d149c1b64b189500405b49bd7936 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 00:59:04 +0900 Subject: [PATCH 13/47] Refactor restriction dispatch with match --- annofabapi/util/attribute_restrictions.py | 843 +++++++++++++--------- 1 file changed, 511 insertions(+), 332 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index 1cf1af96..64f17a46 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -29,7 +29,7 @@ from abc import ABC, abstractmethod from collections.abc import Collection -from typing import Any, Literal +from typing import Any, Literal, NoReturn from pydantic import BaseModel, ConfigDict, Field, model_validator @@ -508,11 +508,12 @@ def get_attribute_restriction_catalog(annotation_specs: dict[str, Any]) -> list[ for attribute in accessor.additionals: attribute_type = attribute["type"] choice_names = None - if attribute_type in {"choice", "select"}: - choice_names = [get_english_message(choice["name"]) for choice in attribute["choices"]] label_names = None - if attribute_type == "link": - label_names = [get_english_message(label["label_name"]) for label in accessor.labels] + match attribute_type: + case "choice" | "select": + choice_names = [get_english_message(choice["name"]) for choice in attribute["choices"]] + case "link": + label_names = [get_english_message(label["label_name"]) for label in accessor.labels] item = AttributeRestrictionCatalogItem( attribute_name=get_english_message(attribute["name"]), attribute_type=attribute_type, @@ -540,6 +541,36 @@ def _from_restriction_dict(obj: dict[str, Any], *, fac: AttributeFactory | None) return _from_condition_dict(attribute_id=attribute_id, condition=condition, fac=fac) +def _get_required_ast_fields(ast_type: RestrictionAstType) -> set[str]: + """ + AST種別ごとに必須なフィールド名を返します。 + + Args: + ast_type: ASTノードの種類です。 + + Returns: + AST種別に対応する必須フィールド名です。 + + Raises: + ValueError: 未知のAST種別が指定された場合 + """ + match ast_type: + case "checked" | "unchecked" | "is_empty" | "is_not_empty": + return {"attribute_name"} + case "equals_string" | "not_equals_string" | "matches_string" | "not_matches_string" | "equals_integer" | "not_equals_integer": + return {"attribute_name", "value"} + case "has_choice" | "not_has_choice": + return {"attribute_name", "choice_name"} + case "has_label": + return {"attribute_name", "label_names"} + case "can_input": + return {"attribute_name", "enable"} + case "imply": + return {"premise", "conclusion"} + case _: + raise ValueError(f"未知のAST種別です。 :: type='{ast_type}'") + + def _validate_restriction_ast(ast: RestrictionAst) -> None: """ `RestrictionAst` の構造がAST種別に整合しているか検証します。 @@ -550,27 +581,7 @@ def _validate_restriction_ast(ast: RestrictionAst) -> None: Raises: ValueError: AST種別に対して必須フィールドが不足している場合、または型が不正な場合 """ - type_to_fields = { - "checked": {"attribute_name"}, - "unchecked": {"attribute_name"}, - "is_empty": {"attribute_name"}, - "is_not_empty": {"attribute_name"}, - "equals_string": {"attribute_name", "value"}, - "not_equals_string": {"attribute_name", "value"}, - "matches_string": {"attribute_name", "value"}, - "not_matches_string": {"attribute_name", "value"}, - "equals_integer": {"attribute_name", "value"}, - "not_equals_integer": {"attribute_name", "value"}, - "has_choice": {"attribute_name", "choice_name"}, - "not_has_choice": {"attribute_name", "choice_name"}, - "has_label": {"attribute_name", "label_names"}, - "can_input": {"attribute_name", "enable"}, - "imply": {"premise", "conclusion"}, - } - required_fields = type_to_fields.get(ast.type) - if required_fields is None: - raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") - + required_fields = _get_required_ast_fields(ast.type) actual_fields = { field_name for field_name, value in ( @@ -587,16 +598,24 @@ def _validate_restriction_ast(ast: RestrictionAst) -> None: if actual_fields != required_fields: raise ValueError(f"AST種別'{ast.type}'のフィールドが不正です。 :: required={sorted(required_fields)}, actual={sorted(actual_fields)}") - if ast.type in {"equals_string", "not_equals_string", "matches_string", "not_matches_string"} and not isinstance(ast.value, str): - raise ValueError(f"AST種別'{ast.type}'の'value'は文字列である必要があります。") - if ast.type in {"equals_integer", "not_equals_integer"} and not isinstance(ast.value, int): - raise ValueError(f"AST種別'{ast.type}'の'value'は整数である必要があります。") - if ast.type in {"has_choice", "not_has_choice"} and not isinstance(ast.choice_name, str): - raise ValueError(f"AST種別'{ast.type}'の'choice_name'は文字列である必要があります。") - if ast.type == "has_label" and (not isinstance(ast.label_names, list) or any(not isinstance(label_name, str) for label_name in ast.label_names)): - raise ValueError("AST種別'has_label'の'label_names'は文字列のリストである必要があります。") - if ast.type == "can_input" and not isinstance(ast.enable, bool): - raise ValueError("AST種別'can_input'の'enable'は真偽値である必要があります。") + match ast.type: + case "equals_string" | "not_equals_string" | "matches_string" | "not_matches_string": + if not isinstance(ast.value, str): + raise ValueError(f"AST種別'{ast.type}'の'value'は文字列である必要があります。") + case "equals_integer" | "not_equals_integer": + if not isinstance(ast.value, int): + raise ValueError(f"AST種別'{ast.type}'の'value'は整数である必要があります。") + case "has_choice" | "not_has_choice": + if not isinstance(ast.choice_name, str): + raise ValueError(f"AST種別'{ast.type}'の'choice_name'は文字列である必要があります。") + case "has_label": + if not isinstance(ast.label_names, list) or any(not isinstance(label_name, str) for label_name in ast.label_names): + raise ValueError("AST種別'has_label'の'label_names'は文字列のリストである必要があります。") + case "can_input": + if not isinstance(ast.enable, bool): + raise ValueError("AST種別'can_input'の'enable'は真偽値である必要があります。") + case _: + pass def _get_allowed_ast_types(attribute_type: str) -> list[RestrictionAstType]: @@ -612,19 +631,21 @@ def _get_allowed_ast_types(attribute_type: str) -> list[RestrictionAstType]: Raises: ValueError: 未対応の属性種類が指定された場合 """ - if attribute_type == "flag": - return ["can_input", "checked", "unchecked"] - if attribute_type in {"text", "comment"}: - return ["can_input", "is_empty", "is_not_empty", "equals_string", "not_equals_string", "matches_string", "not_matches_string"] - if attribute_type == "integer": - return ["can_input", "is_empty", "is_not_empty", "equals_integer", "not_equals_integer"] - if attribute_type == "link": - return ["can_input", "is_empty", "is_not_empty", "has_label"] - if attribute_type == "tracking": - return ["can_input", "is_empty", "is_not_empty", "equals_string", "not_equals_string"] - if attribute_type in {"choice", "select"}: - return ["can_input", "is_empty", "is_not_empty", "has_choice", "not_has_choice"] - raise ValueError(f"未対応の属性種類です。 :: attribute_type='{attribute_type}'") + match attribute_type: + case "flag": + return ["can_input", "checked", "unchecked"] + case "text" | "comment": + return ["can_input", "is_empty", "is_not_empty", "equals_string", "not_equals_string", "matches_string", "not_matches_string"] + case "integer": + return ["can_input", "is_empty", "is_not_empty", "equals_integer", "not_equals_integer"] + case "link": + return ["can_input", "is_empty", "is_not_empty", "has_label"] + case "tracking": + return ["can_input", "is_empty", "is_not_empty", "equals_string", "not_equals_string"] + case "choice" | "select": + return ["can_input", "is_empty", "is_not_empty", "has_choice", "not_has_choice"] + case _: + raise ValueError(f"未対応の属性種類です。 :: attribute_type='{attribute_type}'") def _from_condition_dict(*, attribute_id: str, condition: dict[str, Any], fac: AttributeFactory | None) -> Restriction: @@ -640,14 +661,15 @@ def _from_condition_dict(*, attribute_id: str, condition: dict[str, Any], fac: A 復元した `Restriction` オブジェクトです。 """ condition_type = condition["_type"] - if condition_type == "Imply": - premise_restriction = _from_restriction_dict(condition["premise"], fac=fac) - conclusion_restriction = _from_condition_dict(attribute_id=attribute_id, condition=condition["condition"], fac=fac) - return Imply(premise_restriction=premise_restriction, conclusion_restriction=conclusion_restriction) - - if fac is None: - return _from_condition_dict_without_validation(attribute_id=attribute_id, condition=condition) - return _from_condition_dict_with_validation(attribute_id=attribute_id, condition=condition, fac=fac) + match condition_type: + case "Imply": + premise_restriction = _from_restriction_dict(condition["premise"], fac=fac) + conclusion_restriction = _from_condition_dict(attribute_id=attribute_id, condition=condition["condition"], fac=fac) + return Imply(premise_restriction=premise_restriction, conclusion_restriction=conclusion_restriction) + case _ if fac is None: + return _from_condition_dict_without_validation(attribute_id=attribute_id, condition=condition) + case _: + return _from_condition_dict_with_validation(attribute_id=attribute_id, condition=condition, fac=fac) def _from_condition_dict_without_validation(*, attribute_id: str, condition: dict[str, Any]) -> Restriction: @@ -665,19 +687,21 @@ def _from_condition_dict_without_validation(*, attribute_id: str, condition: dic ValueError: 未知の制約種別が指定された場合 """ condition_type = condition["_type"] - if condition_type == "CanInput": - return CanInput(attribute_id, enable=condition["enable"]) - if condition_type == "Equals": - return Equals(attribute_id, value=condition["value"]) - if condition_type == "NotEquals": - return NotEquals(attribute_id, value=condition["value"]) - if condition_type == "Matches": - return Matches(attribute_id, value=condition["value"]) - if condition_type == "NotMatches": - return NotMatches(attribute_id, value=condition["value"]) - if condition_type == "HasLabel": - return HasLabel(attribute_id, label_ids=condition["labels"]) - raise ValueError(f"未知の制約種別です。 :: _type='{condition_type}'") + match condition_type: + case "CanInput": + return CanInput(attribute_id, enable=condition["enable"]) + case "Equals": + return Equals(attribute_id, value=condition["value"]) + case "NotEquals": + return NotEquals(attribute_id, value=condition["value"]) + case "Matches": + return Matches(attribute_id, value=condition["value"]) + case "NotMatches": + return NotMatches(attribute_id, value=condition["value"]) + case "HasLabel": + return HasLabel(attribute_id, label_ids=condition["labels"]) + case _: + raise ValueError(f"未知の制約種別です。 :: _type='{condition_type}'") def _from_condition_dict_with_validation(*, attribute_id: str, condition: dict[str, Any], fac: AttributeFactory) -> Restriction: @@ -700,70 +724,232 @@ def _from_condition_dict_with_validation(*, attribute_id: str, condition: dict[s attribute_type = attribute["type"] condition_type = condition["_type"] - if condition_type == "CanInput": - return attribute_obj.enabled() if condition["enable"] else attribute_obj.disabled() - - if attribute_type == "flag": - if condition_type == "Equals" and condition["value"] == "true": + match condition_type: + case "CanInput": + return attribute_obj.enabled() if condition["enable"] else attribute_obj.disabled() + case _: + return _from_condition_dict_for_attribute_type( + attribute=attribute, + attribute_obj=attribute_obj, + condition=condition, + attribute_type=attribute_type, + ) + + +def _from_condition_dict_for_attribute_type( + *, + attribute: dict[str, Any], + attribute_obj: Attribute, + condition: dict[str, Any], + attribute_type: str, +) -> Restriction: + match attribute_type: + case "flag": + assert isinstance(attribute_obj, Checkbox) + return _from_flag_condition(attribute=attribute, attribute_obj=attribute_obj, condition=condition) + case "text" | "comment": + assert isinstance(attribute_obj, StringTextbox) + return _from_string_condition(attribute=attribute, attribute_obj=attribute_obj, condition=condition) + case "integer": + assert isinstance(attribute_obj, IntegerTextbox) + return _from_integer_condition(attribute=attribute, attribute_obj=attribute_obj, condition=condition) + case "link": + assert isinstance(attribute_obj, AnnotationLink) + return _from_link_condition(attribute=attribute, attribute_obj=attribute_obj, condition=condition) + case "tracking": + assert isinstance(attribute_obj, TrackingId) + return _from_tracking_condition(attribute=attribute, attribute_obj=attribute_obj, condition=condition) + case "choice" | "select": + assert isinstance(attribute_obj, Selection) + return _from_selection_condition(attribute=attribute, attribute_obj=attribute_obj, condition=condition) + case _: + raise ValueError(f"未対応の属性種類です。 :: attribute_type='{attribute_type}'") + + +def _from_flag_condition(*, attribute: dict[str, Any], attribute_obj: Checkbox, condition: dict[str, Any]) -> Restriction: + match condition["_type"]: + case "Equals" if condition["value"] == "true": return attribute_obj.checked() - if condition_type == "NotEquals" and condition["value"] == "true": + case "NotEquals" if condition["value"] == "true": return attribute_obj.unchecked() - _raise_invalid_restriction(attribute=attribute, condition=condition) + case _: + _raise_invalid_restriction(attribute=attribute, condition=condition) - if attribute_type in {"text", "comment"}: - if condition_type == "Equals": + +def _from_string_condition(*, attribute: dict[str, Any], attribute_obj: StringTextbox, condition: dict[str, Any]) -> Restriction: + match condition["_type"]: + case "Equals": return attribute_obj.equals(condition["value"]) - if condition_type == "NotEquals": + case "NotEquals": return attribute_obj.not_equals(condition["value"]) - if condition_type == "Matches": + case "Matches": return attribute_obj.matches(condition["value"]) - if condition_type == "NotMatches": + case "NotMatches": return attribute_obj.not_matches(condition["value"]) - _raise_invalid_restriction(attribute=attribute, condition=condition) + case _: + _raise_invalid_restriction(attribute=attribute, condition=condition) + - if attribute_type == "integer": - if condition_type == "Equals": +def _from_integer_condition(*, attribute: dict[str, Any], attribute_obj: IntegerTextbox, condition: dict[str, Any]) -> Restriction: + match condition["_type"]: + case "Equals": if condition["value"] == "": return attribute_obj.is_empty() return attribute_obj.equals(_parse_integer_value(condition["value"], attribute=attribute, condition=condition)) - if condition_type == "NotEquals": + case "NotEquals": if condition["value"] == "": return attribute_obj.is_not_empty() return attribute_obj.not_equals(_parse_integer_value(condition["value"], attribute=attribute, condition=condition)) - _raise_invalid_restriction(attribute=attribute, condition=condition) + case _: + _raise_invalid_restriction(attribute=attribute, condition=condition) - if attribute_type == "link": - if condition_type == "HasLabel": + +def _from_link_condition(*, attribute: dict[str, Any], attribute_obj: AnnotationLink, condition: dict[str, Any]) -> Restriction: + match condition["_type"]: + case "HasLabel": return attribute_obj.has_label(label_ids=condition["labels"]) - if condition_type == "Equals" and condition["value"] == "": + case "Equals" if condition["value"] == "": return attribute_obj.is_empty() - if condition_type == "NotEquals" and condition["value"] == "": + case "NotEquals" if condition["value"] == "": return attribute_obj.is_not_empty() - _raise_invalid_restriction(attribute=attribute, condition=condition) + case _: + _raise_invalid_restriction(attribute=attribute, condition=condition) + - if attribute_type == "tracking": - if condition_type == "Equals": +def _from_tracking_condition(*, attribute: dict[str, Any], attribute_obj: TrackingId, condition: dict[str, Any]) -> Restriction: + match condition["_type"]: + case "Equals": if condition["value"] == "": return attribute_obj.is_empty() return attribute_obj.equals(condition["value"]) - if condition_type == "NotEquals": + case "NotEquals": if condition["value"] == "": return attribute_obj.is_not_empty() return attribute_obj.not_equals(condition["value"]) - _raise_invalid_restriction(attribute=attribute, condition=condition) + case _: + _raise_invalid_restriction(attribute=attribute, condition=condition) - if attribute_type in {"choice", "select"}: - if condition_type == "Equals": + +def _from_selection_condition(*, attribute: dict[str, Any], attribute_obj: Selection, condition: dict[str, Any]) -> Restriction: + match condition["_type"]: + case "Equals": if condition["value"] == "": return attribute_obj.is_empty() return attribute_obj.has_choice(choice_id=condition["value"]) - if condition_type == "NotEquals": + case "NotEquals": if condition["value"] == "": return attribute_obj.is_not_empty() return attribute_obj.not_has_choice(choice_id=condition["value"]) - _raise_invalid_restriction(attribute=attribute, condition=condition) + case _: + _raise_invalid_restriction(attribute=attribute, condition=condition) - raise ValueError(f"未対応の属性種類です。 :: attribute_type='{attribute_type}'") + +def _ast_to_atomic_restriction(ast: RestrictionAst, *, fac: AttributeFactory, attribute: dict[str, Any]) -> Restriction: + assert ast.attribute_name is not None + attribute_type = attribute["type"] + + match ast.type: + case "checked": + restriction = fac.checkbox(attribute_name=ast.attribute_name).checked() + case "unchecked": + restriction = fac.checkbox(attribute_name=ast.attribute_name).unchecked() + case "is_empty": + restriction = _attribute_with_empty_check(fac, ast.attribute_name).is_empty() + case "is_not_empty": + restriction = _attribute_with_empty_check(fac, ast.attribute_name).is_not_empty() + case "can_input": + assert ast.enable is not None + attribute_obj = _create_attribute_object_with_name(fac, ast.attribute_name) + restriction = attribute_obj.enabled() if ast.enable else attribute_obj.disabled() + case "equals_string" | "not_equals_string": + restriction = _ast_string_equality_to_restriction(ast=ast, fac=fac, attribute=attribute, attribute_type=attribute_type) + case "matches_string" | "not_matches_string": + restriction = _ast_string_match_to_restriction(ast=ast, fac=fac, attribute=attribute, attribute_type=attribute_type) + case "equals_integer" | "not_equals_integer": + restriction = _ast_integer_to_restriction(ast=ast, fac=fac) + case "has_choice" | "not_has_choice": + restriction = _ast_selection_to_restriction(ast=ast, fac=fac) + case "has_label": + restriction = _ast_label_to_restriction(ast=ast, fac=fac) + case _: + raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") + return restriction + + +def _ast_string_equality_to_restriction( + *, + ast: RestrictionAst, + fac: AttributeFactory, + attribute: dict[str, Any], + attribute_type: str, +) -> Restriction: + assert isinstance(ast.value, str) + attribute_obj: StringTextbox | TrackingId + match attribute_type: + case "text" | "comment": + attribute_obj = fac.string_textbox(attribute_name=ast.attribute_name) + case "tracking": + attribute_obj = fac.tracking_id(attribute_name=ast.attribute_name) + case _: + _raise_invalid_ast(attribute=attribute, ast=ast) + + match ast.type: + case "equals_string": + return attribute_obj.equals(ast.value) + case "not_equals_string": + return attribute_obj.not_equals(ast.value) + case _: + raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") + + +def _ast_string_match_to_restriction( + *, + ast: RestrictionAst, + fac: AttributeFactory, + attribute: dict[str, Any], + attribute_type: str, +) -> Restriction: + assert isinstance(ast.value, str) + if attribute_type not in {"text", "comment"}: + _raise_invalid_ast(attribute=attribute, ast=ast) + + attribute_obj = fac.string_textbox(attribute_name=ast.attribute_name) + match ast.type: + case "matches_string": + return attribute_obj.matches(ast.value) + case "not_matches_string": + return attribute_obj.not_matches(ast.value) + case _: + raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") + + +def _ast_integer_to_restriction(*, ast: RestrictionAst, fac: AttributeFactory) -> Restriction: + assert isinstance(ast.value, int) + attribute_obj = fac.integer_textbox(attribute_name=ast.attribute_name) + match ast.type: + case "equals_integer": + return attribute_obj.equals(ast.value) + case "not_equals_integer": + return attribute_obj.not_equals(ast.value) + case _: + raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") + + +def _ast_selection_to_restriction(*, ast: RestrictionAst, fac: AttributeFactory) -> Restriction: + assert ast.choice_name is not None + attribute_obj = fac.selection(attribute_name=ast.attribute_name) + match ast.type: + case "has_choice": + return attribute_obj.has_choice(choice_name=ast.choice_name) + case "not_has_choice": + return attribute_obj.not_has_choice(choice_name=ast.choice_name) + case _: + raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") + + +def _ast_label_to_restriction(*, ast: RestrictionAst, fac: AttributeFactory) -> Restriction: + assert ast.label_names is not None + return fac.annotation_link(attribute_name=ast.attribute_name).has_label(label_names=ast.label_names) def _create_attribute_object(fac: AttributeFactory, attribute: dict[str, Any]) -> Attribute: @@ -782,19 +968,21 @@ def _create_attribute_object(fac: AttributeFactory, attribute: dict[str, Any]) - """ attribute_id = attribute["additional_data_definition_id"] attribute_type = attribute["type"] - if attribute_type == "flag": - return fac.checkbox(attribute_id=attribute_id) - if attribute_type in {"text", "comment"}: - return fac.string_textbox(attribute_id=attribute_id) - if attribute_type == "integer": - return fac.integer_textbox(attribute_id=attribute_id) - if attribute_type == "link": - return fac.annotation_link(attribute_id=attribute_id) - if attribute_type == "tracking": - return fac.tracking_id(attribute_id=attribute_id) - if attribute_type in {"choice", "select"}: - return fac.selection(attribute_id=attribute_id) - raise ValueError(f"未対応の属性種類です。 :: attribute_type='{attribute_type}'") + match attribute_type: + case "flag": + return fac.checkbox(attribute_id=attribute_id) + case "text" | "comment": + return fac.string_textbox(attribute_id=attribute_id) + case "integer": + return fac.integer_textbox(attribute_id=attribute_id) + case "link": + return fac.annotation_link(attribute_id=attribute_id) + case "tracking": + return fac.tracking_id(attribute_id=attribute_id) + case "choice" | "select": + return fac.selection(attribute_id=attribute_id) + case _: + raise ValueError(f"未対応の属性種類です。 :: attribute_type='{attribute_type}'") def _create_attribute_object_with_name(fac: AttributeFactory, attribute_name: str) -> Attribute: @@ -826,70 +1014,79 @@ def _ast_to_restriction(ast: RestrictionAst, *, fac: AttributeFactory) -> Restri Raises: ValueError: AST種別が未知の場合、または属性型に対して利用できないASTが指定された場合 """ - if ast.type == "imply": - assert ast.premise is not None - assert ast.conclusion is not None - premise_restriction = _ast_to_restriction(ast.premise, fac=fac) - conclusion_restriction = _ast_to_restriction(ast.conclusion, fac=fac) - return premise_restriction.imply(conclusion_restriction) + match ast.type: + case "imply": + assert ast.premise is not None + assert ast.conclusion is not None + premise_restriction = _ast_to_restriction(ast.premise, fac=fac) + conclusion_restriction = _ast_to_restriction(ast.conclusion, fac=fac) + return premise_restriction.imply(conclusion_restriction) assert ast.attribute_name is not None attribute = fac.accessor.get_attribute(attribute_name=ast.attribute_name) + return _ast_to_atomic_restriction(ast, fac=fac, attribute=attribute) + + +def _restriction_to_atomic_ast( + restriction: Restriction, + *, + accessor: AnnotationSpecsAccessor, + attribute: dict[str, Any], + attribute_name: str, +) -> RestrictionAst: attribute_type = attribute["type"] + match restriction: + case CanInput(enable=enable): + return RestrictionAst(type="can_input", attribute_name=attribute_name, enable=enable) + case Equals(value=value): + return _equals_restriction_to_ast(attribute=attribute, attribute_name=attribute_name, attribute_type=attribute_type, value=value) + case NotEquals(value=value): + return _not_equals_restriction_to_ast(attribute=attribute, attribute_name=attribute_name, attribute_type=attribute_type, value=value) + case Matches(value=value) if attribute_type in {"text", "comment"}: + return RestrictionAst(type="matches_string", attribute_name=attribute_name, value=value) + case NotMatches(value=value) if attribute_type in {"text", "comment"}: + return RestrictionAst(type="not_matches_string", attribute_name=attribute_name, value=value) + case HasLabel(label_ids=label_ids) if attribute_type == "link": + label_names = [get_english_message(accessor.get_label(label_id=label_id)["label_name"]) for label_id in label_ids] + return RestrictionAst(type="has_label", attribute_name=attribute_name, label_names=label_names) + case _: + raise ValueError(f"RestrictionをASTへ変換できません。 :: restriction={restriction.to_dict()}") + + +def _equals_restriction_to_ast(*, attribute: dict[str, Any], attribute_name: str, attribute_type: str, value: str) -> RestrictionAst: + match attribute_type: + case "flag" if value == "true": + return RestrictionAst(type="checked", attribute_name=attribute_name) + case "text" | "comment" | "integer" | "link" | "tracking" | "choice" | "select" if value == "": + return RestrictionAst(type="is_empty", attribute_name=attribute_name) + case "text" | "comment" | "tracking": + return RestrictionAst(type="equals_string", attribute_name=attribute_name, value=value) + case "integer": + return RestrictionAst(type="equals_integer", attribute_name=attribute_name, value=int(value)) + case "choice" | "select": + choice = get_choice(attribute["choices"], choice_id=value) + return RestrictionAst(type="has_choice", attribute_name=attribute_name, choice_name=get_english_message(choice["name"])) + case _: + raise ValueError(f"RestrictionをASTへ変換できません。 :: restriction_type='Equals', attribute_type='{attribute_type}', value={value!r}") - if ast.type == "checked": - return fac.checkbox(attribute_name=ast.attribute_name).checked() - if ast.type == "unchecked": - return fac.checkbox(attribute_name=ast.attribute_name).unchecked() - if ast.type == "is_empty": - return _attribute_with_empty_check(fac, ast.attribute_name).is_empty() - if ast.type == "is_not_empty": - return _attribute_with_empty_check(fac, ast.attribute_name).is_not_empty() - if ast.type == "can_input": - assert ast.enable is not None - attribute_obj = _create_attribute_object_with_name(fac, ast.attribute_name) - return attribute_obj.enabled() if ast.enable else attribute_obj.disabled() - if ast.type == "equals_string": - assert isinstance(ast.value, str) - if attribute_type in {"text", "comment"}: - return fac.string_textbox(attribute_name=ast.attribute_name).equals(ast.value) - if attribute_type == "tracking": - return fac.tracking_id(attribute_name=ast.attribute_name).equals(ast.value) - _raise_invalid_ast(attribute=attribute, ast=ast) - if ast.type == "not_equals_string": - assert isinstance(ast.value, str) - if attribute_type in {"text", "comment"}: - return fac.string_textbox(attribute_name=ast.attribute_name).not_equals(ast.value) - if attribute_type == "tracking": - return fac.tracking_id(attribute_name=ast.attribute_name).not_equals(ast.value) - _raise_invalid_ast(attribute=attribute, ast=ast) - if ast.type == "matches_string": - assert isinstance(ast.value, str) - if attribute_type in {"text", "comment"}: - return fac.string_textbox(attribute_name=ast.attribute_name).matches(ast.value) - _raise_invalid_ast(attribute=attribute, ast=ast) - if ast.type == "not_matches_string": - assert isinstance(ast.value, str) - if attribute_type in {"text", "comment"}: - return fac.string_textbox(attribute_name=ast.attribute_name).not_matches(ast.value) - _raise_invalid_ast(attribute=attribute, ast=ast) - if ast.type == "equals_integer": - assert isinstance(ast.value, int) - return fac.integer_textbox(attribute_name=ast.attribute_name).equals(ast.value) - if ast.type == "not_equals_integer": - assert isinstance(ast.value, int) - return fac.integer_textbox(attribute_name=ast.attribute_name).not_equals(ast.value) - if ast.type == "has_choice": - assert ast.choice_name is not None - return fac.selection(attribute_name=ast.attribute_name).has_choice(choice_name=ast.choice_name) - if ast.type == "not_has_choice": - assert ast.choice_name is not None - return fac.selection(attribute_name=ast.attribute_name).not_has_choice(choice_name=ast.choice_name) - if ast.type == "has_label": - assert ast.label_names is not None - return fac.annotation_link(attribute_name=ast.attribute_name).has_label(label_names=ast.label_names) - - raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") + +def _not_equals_restriction_to_ast(*, attribute: dict[str, Any], attribute_name: str, attribute_type: str, value: str) -> RestrictionAst: + match attribute_type: + case "flag" if value == "true": + return RestrictionAst(type="unchecked", attribute_name=attribute_name) + case "text" | "comment" | "integer" | "link" | "tracking" | "choice" | "select" if value == "": + return RestrictionAst(type="is_not_empty", attribute_name=attribute_name) + case "text" | "comment" | "tracking": + return RestrictionAst(type="not_equals_string", attribute_name=attribute_name, value=value) + case "integer": + return RestrictionAst(type="not_equals_integer", attribute_name=attribute_name, value=int(value)) + case "choice" | "select": + choice = get_choice(attribute["choices"], choice_id=value) + return RestrictionAst(type="not_has_choice", attribute_name=attribute_name, choice_name=get_english_message(choice["name"])) + case _: + raise ValueError( + f"RestrictionをASTへ変換できません。 :: restriction_type='NotEquals', attribute_type='{attribute_type}', value={value!r}" + ) def _attribute_with_empty_check(fac: AttributeFactory, attribute_name: str) -> EmptyCheckMixin: @@ -914,10 +1111,11 @@ def _attribute_with_empty_check(fac: AttributeFactory, attribute_name: str) -> E condition={"_type": "EmptyCheck"}, detail="空判定はこの属性種類では利用できません。", ) + assert isinstance(attribute_obj, EmptyCheckMixin) return attribute_obj -def _raise_invalid_ast(*, attribute: dict[str, Any], ast: RestrictionAst) -> None: +def _raise_invalid_ast(*, attribute: dict[str, Any], ast: RestrictionAst) -> NoReturn: """ 属性型に対して不正なAST種別が指定されたことを表す例外を送出します。 @@ -954,7 +1152,7 @@ def _parse_integer_value(value: str, *, attribute: dict[str, Any], condition: di raise AssertionError("unreachable") from exc -def _raise_invalid_restriction(*, attribute: dict[str, Any], condition: dict[str, Any], detail: str | None = None) -> None: +def _raise_invalid_restriction(*, attribute: dict[str, Any], condition: dict[str, Any], detail: str | None = None) -> NoReturn: """ 属性型に対して不正な制約が指定されたことを表す例外を送出します。 @@ -987,57 +1185,17 @@ def _restriction_to_ast(restriction: Restriction, *, accessor: AnnotationSpecsAc Raises: ValueError: ASTへ変換できない制約が含まれている場合 """ - if isinstance(restriction, Imply): - return RestrictionAst( - type="imply", - premise=_restriction_to_ast(restriction.premise_restriction, accessor=accessor), - conclusion=_restriction_to_ast(restriction.conclusion_restriction, accessor=accessor), - ) + match restriction: + case Imply(premise_restriction=premise_restriction, conclusion_restriction=conclusion_restriction): + return RestrictionAst( + type="imply", + premise=_restriction_to_ast(premise_restriction, accessor=accessor), + conclusion=_restriction_to_ast(conclusion_restriction, accessor=accessor), + ) attribute = accessor.get_attribute(attribute_id=restriction.attribute_id) attribute_name = get_english_message(attribute["name"]) - attribute_type = attribute["type"] - - if isinstance(restriction, CanInput): - return RestrictionAst(type="can_input", attribute_name=attribute_name, enable=restriction.enable) - - if isinstance(restriction, Equals): - if attribute_type == "flag" and restriction.value == "true": - return RestrictionAst(type="checked", attribute_name=attribute_name) - if restriction.value == "" and attribute_type in {"text", "comment", "integer", "link", "tracking", "choice", "select"}: - return RestrictionAst(type="is_empty", attribute_name=attribute_name) - if attribute_type in {"text", "comment", "tracking"}: - return RestrictionAst(type="equals_string", attribute_name=attribute_name, value=restriction.value) - if attribute_type == "integer": - return RestrictionAst(type="equals_integer", attribute_name=attribute_name, value=int(restriction.value)) - if attribute_type in {"choice", "select"}: - choice = get_choice(attribute["choices"], choice_id=restriction.value) - return RestrictionAst(type="has_choice", attribute_name=attribute_name, choice_name=get_english_message(choice["name"])) - - if isinstance(restriction, NotEquals): - if attribute_type == "flag" and restriction.value == "true": - return RestrictionAst(type="unchecked", attribute_name=attribute_name) - if restriction.value == "" and attribute_type in {"text", "comment", "integer", "link", "tracking", "choice", "select"}: - return RestrictionAst(type="is_not_empty", attribute_name=attribute_name) - if attribute_type in {"text", "comment", "tracking"}: - return RestrictionAst(type="not_equals_string", attribute_name=attribute_name, value=restriction.value) - if attribute_type == "integer": - return RestrictionAst(type="not_equals_integer", attribute_name=attribute_name, value=int(restriction.value)) - if attribute_type in {"choice", "select"}: - choice = get_choice(attribute["choices"], choice_id=restriction.value) - return RestrictionAst(type="not_has_choice", attribute_name=attribute_name, choice_name=get_english_message(choice["name"])) - - if isinstance(restriction, Matches) and attribute_type in {"text", "comment"}: - return RestrictionAst(type="matches_string", attribute_name=attribute_name, value=restriction.value) - - if isinstance(restriction, NotMatches) and attribute_type in {"text", "comment"}: - return RestrictionAst(type="not_matches_string", attribute_name=attribute_name, value=restriction.value) - - if isinstance(restriction, HasLabel) and attribute_type == "link": - label_names = [get_english_message(accessor.get_label(label_id=label_id)["label_name"]) for label_id in restriction.label_ids] - return RestrictionAst(type="has_label", attribute_name=attribute_name, label_names=label_names) - - raise ValueError(f"RestrictionをASTへ変換できません。 :: restriction={restriction.to_dict()}") + return _restriction_to_atomic_ast(restriction, accessor=accessor, attribute=attribute, attribute_name=attribute_name) def _restriction_to_python_expr(restriction: Restriction, *, accessor: AnnotationSpecsAccessor, factory_name: str) -> str: @@ -1055,77 +1213,99 @@ def _restriction_to_python_expr(restriction: Restriction, *, accessor: Annotatio Raises: ValueError: Python 式へ変換できない制約が含まれている場合 """ - if isinstance(restriction, Imply): - premise_expr = _restriction_to_python_expr(restriction.premise_restriction, accessor=accessor, factory_name=factory_name) - conclusion_expr = _restriction_to_python_expr(restriction.conclusion_restriction, accessor=accessor, factory_name=factory_name) - return f"{premise_expr}.imply({conclusion_expr})" + match restriction: + case Imply(premise_restriction=premise_restriction, conclusion_restriction=conclusion_restriction): + premise_expr = _restriction_to_python_expr(premise_restriction, accessor=accessor, factory_name=factory_name) + conclusion_expr = _restriction_to_python_expr(conclusion_restriction, accessor=accessor, factory_name=factory_name) + return f"{premise_expr}.imply({conclusion_expr})" attribute = accessor.get_attribute(attribute_id=restriction.attribute_id) attribute_expr = _attribute_to_python_expr(attribute, factory_name=factory_name) - attribute_type = attribute["type"] + return _restriction_to_atomic_python_expr(restriction, accessor=accessor, attribute=attribute, attribute_expr=attribute_expr) - if isinstance(restriction, CanInput): - return f"{attribute_expr}.enabled()" if restriction.enable else f"{attribute_expr}.disabled()" - if isinstance(restriction, Equals): - if attribute_type == "flag" and restriction.value == "true": - return f"{attribute_expr}.checked()" - if attribute_type in {"text", "comment"}: - if restriction.value == "": - return f"{attribute_expr}.is_empty()" - return f"{attribute_expr}.equals({_repr_python_value(restriction.value)})" - if attribute_type == "integer": - if restriction.value == "": - return f"{attribute_expr}.is_empty()" - return f"{attribute_expr}.equals({int(restriction.value)})" - if attribute_type == "link" and restriction.value == "": - return f"{attribute_expr}.is_empty()" - if attribute_type == "tracking": - if restriction.value == "": - return f"{attribute_expr}.is_empty()" - return f"{attribute_expr}.equals({_repr_python_value(restriction.value)})" - if attribute_type in {"choice", "select"}: - if restriction.value == "": +def _restriction_to_atomic_python_expr( + restriction: Restriction, + *, + accessor: AnnotationSpecsAccessor, + attribute: dict[str, Any], + attribute_expr: str, +) -> str: + attribute_type = attribute["type"] + match restriction: + case CanInput(enable=enable): + return f"{attribute_expr}.enabled()" if enable else f"{attribute_expr}.disabled()" + case Equals(value=value): + return _equals_restriction_to_python_expr(attribute=attribute, attribute_expr=attribute_expr, attribute_type=attribute_type, value=value) + case NotEquals(value=value): + return _not_equals_restriction_to_python_expr( + attribute=attribute, + attribute_expr=attribute_expr, + attribute_type=attribute_type, + value=value, + ) + case Matches(value=value) if attribute_type in {"text", "comment"}: + return f"{attribute_expr}.matches({_repr_python_value(value)})" + case NotMatches(value=value) if attribute_type in {"text", "comment"}: + return f"{attribute_expr}.not_matches({_repr_python_value(value)})" + case HasLabel(label_ids=label_ids) if attribute_type == "link": + label_names = [get_english_message(accessor.get_label(label_id=label_id)["label_name"]) for label_id in label_ids] + return f"{attribute_expr}.has_label(label_names={_repr_python_value(label_names)})" + case _: + raise ValueError(f"Restrictionを高水準APIのPython式へ変換できません。 :: restriction={restriction.to_dict()}") + + +def _equals_restriction_to_python_expr(*, attribute: dict[str, Any], attribute_expr: str, attribute_type: str, value: str) -> str: + if value == "": + match attribute_type: + case "text" | "comment" | "integer" | "link" | "tracking" | "choice" | "select": return f"{attribute_expr}.is_empty()" - choice = get_choice(attribute["choices"], choice_id=restriction.value) - choice_name = get_english_message(choice["name"]) - return f"{attribute_expr}.has_choice(choice_name={_repr_python_value(choice_name)})" - if isinstance(restriction, NotEquals): - if attribute_type == "flag" and restriction.value == "true": - return f"{attribute_expr}.unchecked()" - if attribute_type in {"text", "comment"}: - if restriction.value == "": - return f"{attribute_expr}.is_not_empty()" - return f"{attribute_expr}.not_equals({_repr_python_value(restriction.value)})" - if attribute_type == "integer": - if restriction.value == "": - return f"{attribute_expr}.is_not_empty()" - return f"{attribute_expr}.not_equals({int(restriction.value)})" - if attribute_type == "link" and restriction.value == "": - return f"{attribute_expr}.is_not_empty()" - if attribute_type == "tracking": - if restriction.value == "": - return f"{attribute_expr}.is_not_empty()" - return f"{attribute_expr}.not_equals({_repr_python_value(restriction.value)})" - if attribute_type in {"choice", "select"}: - if restriction.value == "": - return f"{attribute_expr}.is_not_empty()" - choice = get_choice(attribute["choices"], choice_id=restriction.value) + match attribute_type: + case "flag" if value == "true": + expr = f"{attribute_expr}.checked()" + case "text" | "comment": + expr = f"{attribute_expr}.equals({_repr_python_value(value)})" + case "integer": + expr = f"{attribute_expr}.equals({int(value)})" + case "tracking": + expr = f"{attribute_expr}.equals({_repr_python_value(value)})" + case "choice" | "select": + choice = get_choice(attribute["choices"], choice_id=value) choice_name = get_english_message(choice["name"]) - return f"{attribute_expr}.not_has_choice(choice_name={_repr_python_value(choice_name)})" - - if isinstance(restriction, Matches) and attribute_type in {"text", "comment"}: - return f"{attribute_expr}.matches({_repr_python_value(restriction.value)})" - - if isinstance(restriction, NotMatches) and attribute_type in {"text", "comment"}: - return f"{attribute_expr}.not_matches({_repr_python_value(restriction.value)})" - - if isinstance(restriction, HasLabel) and attribute_type == "link": - label_names = [get_english_message(accessor.get_label(label_id=label_id)["label_name"]) for label_id in restriction.label_ids] - return f"{attribute_expr}.has_label(label_names={_repr_python_value(label_names)})" + expr = f"{attribute_expr}.has_choice(choice_name={_repr_python_value(choice_name)})" + case _: + raise ValueError( + f"Restrictionを高水準APIのPython式へ変換できません。 :: restriction_type='Equals', attribute_type='{attribute_type}', value={value!r}" + ) + return expr + + +def _not_equals_restriction_to_python_expr(*, attribute: dict[str, Any], attribute_expr: str, attribute_type: str, value: str) -> str: + if value == "": + match attribute_type: + case "text" | "comment" | "integer" | "link" | "tracking" | "choice" | "select": + return f"{attribute_expr}.is_not_empty()" - raise ValueError(f"Restrictionを高水準APIのPython式へ変換できません。 :: restriction={restriction.to_dict()}") + match attribute_type: + case "flag" if value == "true": + expr = f"{attribute_expr}.unchecked()" + case "text" | "comment": + expr = f"{attribute_expr}.not_equals({_repr_python_value(value)})" + case "integer": + expr = f"{attribute_expr}.not_equals({int(value)})" + case "tracking": + expr = f"{attribute_expr}.not_equals({_repr_python_value(value)})" + case "choice" | "select": + choice = get_choice(attribute["choices"], choice_id=value) + choice_name = get_english_message(choice["name"]) + expr = f"{attribute_expr}.not_has_choice(choice_name={_repr_python_value(choice_name)})" + case _: + raise ValueError( + "Restrictionを高水準APIのPython式へ変換できません。 " + f":: restriction_type='NotEquals', attribute_type='{attribute_type}', value={value!r}" + ) + return expr def _attribute_to_python_expr(attribute: dict[str, Any], *, factory_name: str) -> str: @@ -1145,20 +1325,21 @@ def _attribute_to_python_expr(attribute: dict[str, Any], *, factory_name: str) - attribute_name = get_english_message(attribute["name"]) attribute_type = attribute["type"] - if attribute_type == "flag": - factory_method = "checkbox" - elif attribute_type in {"text", "comment"}: - factory_method = "string_textbox" - elif attribute_type == "integer": - factory_method = "integer_textbox" - elif attribute_type == "link": - factory_method = "annotation_link" - elif attribute_type == "tracking": - factory_method = "tracking_id" - elif attribute_type in {"choice", "select"}: - factory_method = "selection" - else: - raise ValueError(f"未対応の属性種類です。 :: attribute_type='{attribute_type}'") + match attribute_type: + case "flag": + factory_method = "checkbox" + case "text" | "comment": + factory_method = "string_textbox" + case "integer": + factory_method = "integer_textbox" + case "link": + factory_method = "annotation_link" + case "tracking": + factory_method = "tracking_id" + case "choice" | "select": + factory_method = "selection" + case _: + raise ValueError(f"未対応の属性種類です。 :: attribute_type='{attribute_type}'") return f"{factory_name}.{factory_method}(attribute_name={_repr_python_value(attribute_name)})" @@ -1183,39 +1364,37 @@ def _ast_to_human_readable(ast: RestrictionAst) -> str: assert ast.attribute_name is not None attribute_name = _quote_human(ast.attribute_name) - - if ast.type == "checked": - return f"{attribute_name} EQUALS 'true'" - if ast.type == "unchecked": - return f"{attribute_name} DOES NOT EQUAL 'true'" - if ast.type == "is_empty": - return f"{attribute_name} EQUALS ''" - if ast.type == "is_not_empty": - return f"{attribute_name} DOES NOT EQUAL ''" - if ast.type == "equals_string": - return f"{attribute_name} EQUALS {_quote_human(ast.value)}" - if ast.type == "not_equals_string": - return f"{attribute_name} DOES NOT EQUAL {_quote_human(ast.value)}" - if ast.type == "matches_string": - return f"{attribute_name} MATCHES {_quote_human(ast.value)}" - if ast.type == "not_matches_string": - return f"{attribute_name} DOES NOT MATCH {_quote_human(ast.value)}" - if ast.type == "equals_integer": - return f"{attribute_name} EQUALS {_quote_human(ast.value)}" - if ast.type == "not_equals_integer": - return f"{attribute_name} DOES NOT EQUAL {_quote_human(ast.value)}" - if ast.type == "has_choice": - return f"{attribute_name} EQUALS {_quote_human(ast.choice_name)}" - if ast.type == "not_has_choice": - return f"{attribute_name} DOES NOT EQUAL {_quote_human(ast.choice_name)}" - if ast.type == "has_label": - assert ast.label_names is not None - return f"{attribute_name} HAS LABEL {', '.join(_quote_human(label_name) for label_name in ast.label_names)}" - if ast.type == "can_input": - assert ast.enable is not None - return f"{attribute_name} CAN INPUT" if ast.enable else f"{attribute_name} CANNOT INPUT" - - raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") + simple_text_map = { + "checked": f"{attribute_name} EQUALS 'true'", + "unchecked": f"{attribute_name} DOES NOT EQUAL 'true'", + "is_empty": f"{attribute_name} EQUALS ''", + "is_not_empty": f"{attribute_name} DOES NOT EQUAL ''", + } + if ast.type in simple_text_map: + return simple_text_map[ast.type] + + match ast.type: + case "equals_string" | "equals_integer": + text = f"{attribute_name} EQUALS {_quote_human(ast.value)}" + case "not_equals_string" | "not_equals_integer": + text = f"{attribute_name} DOES NOT EQUAL {_quote_human(ast.value)}" + case "matches_string": + text = f"{attribute_name} MATCHES {_quote_human(ast.value)}" + case "not_matches_string": + text = f"{attribute_name} DOES NOT MATCH {_quote_human(ast.value)}" + case "has_choice": + text = f"{attribute_name} EQUALS {_quote_human(ast.choice_name)}" + case "not_has_choice": + text = f"{attribute_name} DOES NOT EQUAL {_quote_human(ast.choice_name)}" + case "has_label": + assert ast.label_names is not None + text = f"{attribute_name} HAS LABEL {', '.join(_quote_human(label_name) for label_name in ast.label_names)}" + case "can_input": + assert ast.enable is not None + text = f"{attribute_name} CAN INPUT" if ast.enable else f"{attribute_name} CANNOT INPUT" + case _: + raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") + return text def _repr_python_value(value: object) -> str: From a470d59bff25304f0a500776ad1ddacc0086084f Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 01:24:21 +0900 Subject: [PATCH 14/47] Refactor RestrictionAst human readable conversion --- annofabapi/util/attribute_restrictions.py | 95 ++++++++++------------- 1 file changed, 41 insertions(+), 54 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index 64f17a46..e89bb85d 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -455,8 +455,48 @@ def to_human_readable(self) -> str: Returns: CLIなどで表示しやすい文字列表現です。 + + Raises: + ValueError: 未知のAST種別が指定された場合 """ - return _ast_to_human_readable(self) + if self.type == "imply": + assert self.premise is not None + assert self.conclusion is not None + return f"{self.conclusion.to_human_readable()} IF {self.premise.to_human_readable()}" + + assert self.attribute_name is not None + attribute_name = _quote_human(self.attribute_name) + simple_text_map = { + "checked": f"{attribute_name} EQUALS 'true'", + "unchecked": f"{attribute_name} DOES NOT EQUAL 'true'", + "is_empty": f"{attribute_name} EQUALS ''", + "is_not_empty": f"{attribute_name} DOES NOT EQUAL ''", + } + if self.type in simple_text_map: + return simple_text_map[self.type] + + match self.type: + case "equals_string" | "equals_integer": + text = f"{attribute_name} EQUALS {_quote_human(self.value)}" + case "not_equals_string" | "not_equals_integer": + text = f"{attribute_name} DOES NOT EQUAL {_quote_human(self.value)}" + case "matches_string": + text = f"{attribute_name} MATCHES {_quote_human(self.value)}" + case "not_matches_string": + text = f"{attribute_name} DOES NOT MATCH {_quote_human(self.value)}" + case "has_choice": + text = f"{attribute_name} EQUALS {_quote_human(self.choice_name)}" + case "not_has_choice": + text = f"{attribute_name} DOES NOT EQUAL {_quote_human(self.choice_name)}" + case "has_label": + assert self.label_names is not None + text = f"{attribute_name} HAS LABEL {', '.join(_quote_human(label_name) for label_name in self.label_names)}" + case "can_input": + assert self.enable is not None + text = f"{attribute_name} CAN INPUT" if self.enable else f"{attribute_name} CANNOT INPUT" + case _: + raise ValueError(f"未知のAST種別です。 :: type='{self.type}'") + return text RestrictionAst.model_rebuild() @@ -1344,59 +1384,6 @@ def _attribute_to_python_expr(attribute: dict[str, Any], *, factory_name: str) - return f"{factory_name}.{factory_method}(attribute_name={_repr_python_value(attribute_name)})" -def _ast_to_human_readable(ast: RestrictionAst) -> str: - """ - ASTを人間向けの読みやすい文字列表現へ変換します。 - - Args: - ast: 変換元のASTです。 - - Returns: - 人間向けの文字列表現です。 - - Raises: - ValueError: 未知のAST種別が指定された場合 - """ - if ast.type == "imply": - assert ast.premise is not None - assert ast.conclusion is not None - return f"{ast.conclusion.to_human_readable()} IF {ast.premise.to_human_readable()}" - - assert ast.attribute_name is not None - attribute_name = _quote_human(ast.attribute_name) - simple_text_map = { - "checked": f"{attribute_name} EQUALS 'true'", - "unchecked": f"{attribute_name} DOES NOT EQUAL 'true'", - "is_empty": f"{attribute_name} EQUALS ''", - "is_not_empty": f"{attribute_name} DOES NOT EQUAL ''", - } - if ast.type in simple_text_map: - return simple_text_map[ast.type] - - match ast.type: - case "equals_string" | "equals_integer": - text = f"{attribute_name} EQUALS {_quote_human(ast.value)}" - case "not_equals_string" | "not_equals_integer": - text = f"{attribute_name} DOES NOT EQUAL {_quote_human(ast.value)}" - case "matches_string": - text = f"{attribute_name} MATCHES {_quote_human(ast.value)}" - case "not_matches_string": - text = f"{attribute_name} DOES NOT MATCH {_quote_human(ast.value)}" - case "has_choice": - text = f"{attribute_name} EQUALS {_quote_human(ast.choice_name)}" - case "not_has_choice": - text = f"{attribute_name} DOES NOT EQUAL {_quote_human(ast.choice_name)}" - case "has_label": - assert ast.label_names is not None - text = f"{attribute_name} HAS LABEL {', '.join(_quote_human(label_name) for label_name in ast.label_names)}" - case "can_input": - assert ast.enable is not None - text = f"{attribute_name} CAN INPUT" if ast.enable else f"{attribute_name} CANNOT INPUT" - case _: - raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") - return text - - def _repr_python_value(value: object) -> str: """ Python 式へ埋め込む値を `repr()` で文字列化します。 From 60fe54e35d2020bfc8fe38d4bb121d6d1743b3fc Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 01:32:03 +0900 Subject: [PATCH 15/47] Improve human-readable restriction text --- annofabapi/util/attribute_restrictions.py | 97 +++++++++++++++++++---- tests/util/test_attribute_restrictions.py | 31 +++++++- 2 files changed, 111 insertions(+), 17 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index e89bb85d..4c9c9c16 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -460,40 +460,38 @@ def to_human_readable(self) -> str: ValueError: 未知のAST種別が指定された場合 """ if self.type == "imply": - assert self.premise is not None - assert self.conclusion is not None - return f"{self.conclusion.to_human_readable()} IF {self.premise.to_human_readable()}" + return _imply_to_human_readable(self) assert self.attribute_name is not None attribute_name = _quote_human(self.attribute_name) simple_text_map = { - "checked": f"{attribute_name} EQUALS 'true'", - "unchecked": f"{attribute_name} DOES NOT EQUAL 'true'", - "is_empty": f"{attribute_name} EQUALS ''", - "is_not_empty": f"{attribute_name} DOES NOT EQUAL ''", + "checked": f"{attribute_name} is checked", + "unchecked": f"{attribute_name} is unchecked", + "is_empty": f"{attribute_name} is empty", + "is_not_empty": f"{attribute_name} is not empty", } if self.type in simple_text_map: return simple_text_map[self.type] match self.type: case "equals_string" | "equals_integer": - text = f"{attribute_name} EQUALS {_quote_human(self.value)}" + text = f"{attribute_name} is {_repr_human_value(self.value)}" case "not_equals_string" | "not_equals_integer": - text = f"{attribute_name} DOES NOT EQUAL {_quote_human(self.value)}" + text = f"{attribute_name} is not {_repr_human_value(self.value)}" case "matches_string": - text = f"{attribute_name} MATCHES {_quote_human(self.value)}" + text = f"{attribute_name} matches {_repr_human_value(self.value)}" case "not_matches_string": - text = f"{attribute_name} DOES NOT MATCH {_quote_human(self.value)}" + text = f"{attribute_name} does not match {_repr_human_value(self.value)}" case "has_choice": - text = f"{attribute_name} EQUALS {_quote_human(self.choice_name)}" + text = f"{attribute_name} is {_repr_human_value(self.choice_name)}" case "not_has_choice": - text = f"{attribute_name} DOES NOT EQUAL {_quote_human(self.choice_name)}" + text = f"{attribute_name} is not {_repr_human_value(self.choice_name)}" case "has_label": assert self.label_names is not None - text = f"{attribute_name} HAS LABEL {', '.join(_quote_human(label_name) for label_name in self.label_names)}" + text = f"{attribute_name} has labels {', '.join(_repr_human_value(label_name) for label_name in self.label_names)}" case "can_input": assert self.enable is not None - text = f"{attribute_name} CAN INPUT" if self.enable else f"{attribute_name} CANNOT INPUT" + text = f"{attribute_name} can be edited" if self.enable else f"{attribute_name} is read-only" case _: raise ValueError(f"未知のAST種別です。 :: type='{self.type}'") return text @@ -1408,3 +1406,72 @@ def _quote_human(value: object) -> str: シングルクォートで囲んだ文字列表現です。 """ return f"'{value}'" + + +def _repr_human_value(value: object) -> str: + """ + 人間向け表示用に値を読みやすく文字列化します。 + + Args: + value: 表示対象の値です。 + + Returns: + 文字列はクォート付き、それ以外は自然な文字列表現です。 + """ + return repr(value) + + +def _imply_to_human_readable(ast: RestrictionAst) -> str: + """ + `imply` AST を自然文スタイルの文字列へ変換します。 + + 右側にネストした `imply` は条件を畳み込んで、 + `If A and B, C.` のような形へ変換します。 + + Args: + ast: `imply` 種別のASTです。 + + Returns: + 自然文スタイルの文字列表現です。 + """ + conditions, conclusion = _flatten_imply_conditions(ast) + conditions_text = " and ".join(_to_human_condition_text(condition) for condition in conditions) + return f"If {conditions_text}, {conclusion.to_human_readable()}." + + +def _flatten_imply_conditions(ast: RestrictionAst) -> tuple[list[RestrictionAst], RestrictionAst]: + """ + 右側にネストした `imply` を条件列と結論へ分解します。 + + Args: + ast: `imply` 種別のASTです。 + + Returns: + 条件ASTの一覧と最終的な結論ASTです。 + """ + assert ast.premise is not None + assert ast.conclusion is not None + + conditions = [ast.premise] + conclusion = ast.conclusion + while conclusion.type == "imply": + assert conclusion.premise is not None + assert conclusion.conclusion is not None + conditions.append(conclusion.premise) + conclusion = conclusion.conclusion + return conditions, conclusion + + +def _to_human_condition_text(ast: RestrictionAst) -> str: + """ + 条件節で使う人間向け文字列表現へ変換します。 + + Args: + ast: 変換対象のASTです。 + + Returns: + 条件節で使いやすい文字列表現です。 + """ + if ast.type == "imply": + return f"({ast.to_human_readable()})" + return ast.to_human_readable() diff --git a/tests/util/test_attribute_restrictions.py b/tests/util/test_attribute_restrictions.py index 9ba639af..e7b52242 100644 --- a/tests/util/test_attribute_restrictions.py +++ b/tests/util/test_attribute_restrictions.py @@ -303,7 +303,34 @@ def test__to_human_readable(self): actual = restriction.to_human_readable(accessor.annotation_specs) - assert actual == "'note' DOES NOT EQUAL '' IF 'occluded' EQUALS 'true'" + assert actual == "If 'occluded' is checked, 'note' is not empty." + + def test__to_human_readable__右側にネストしたimplyは条件をまとめる(self): + restriction = Restriction.from_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": "Imply", + "premise": { + "additional_data_definition_id": "ec27de5d-122c-40e7-89bc-5500e37bae6a", + "condition": {"_type": "Equals", "value": "2"}, + }, + "condition": {"_type": "NotEquals", "value": ""}, + }, + }, + }, + annotation_specs=accessor.annotation_specs, + ) + + actual = restriction.to_human_readable(accessor.annotation_specs) + + assert actual == "If 'occluded' is checked and 'traffic_lane' is 2, 'note' is not empty." def test__from_dict__annotation_specsを指定しない場合は妥当性検証しない(self): restriction_dict = { @@ -415,7 +442,7 @@ def test__to_human_readable(self): actual = ast.to_human_readable() - assert actual == "'link_car' HAS LABEL 'car', 'number_plate'" + assert actual == "'link_car' has labels 'car', 'number_plate'" def test__invalid_fields(self): with pytest.raises(ValidationError): From ffd5323d7fb89e6e4540e39d3f2cac34e3f4401b Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 01:35:32 +0900 Subject: [PATCH 16/47] Inline repr in human-readable text --- annofabapi/util/attribute_restrictions.py | 27 ++++++----------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index 4c9c9c16..76ecce8c 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -475,20 +475,20 @@ def to_human_readable(self) -> str: match self.type: case "equals_string" | "equals_integer": - text = f"{attribute_name} is {_repr_human_value(self.value)}" + text = f"{attribute_name} is " + repr(self.value) case "not_equals_string" | "not_equals_integer": - text = f"{attribute_name} is not {_repr_human_value(self.value)}" + text = f"{attribute_name} is not " + repr(self.value) case "matches_string": - text = f"{attribute_name} matches {_repr_human_value(self.value)}" + text = f"{attribute_name} matches " + repr(self.value) case "not_matches_string": - text = f"{attribute_name} does not match {_repr_human_value(self.value)}" + text = f"{attribute_name} does not match " + repr(self.value) case "has_choice": - text = f"{attribute_name} is {_repr_human_value(self.choice_name)}" + text = f"{attribute_name} is " + repr(self.choice_name) case "not_has_choice": - text = f"{attribute_name} is not {_repr_human_value(self.choice_name)}" + text = f"{attribute_name} is not " + repr(self.choice_name) case "has_label": assert self.label_names is not None - text = f"{attribute_name} has labels {', '.join(_repr_human_value(label_name) for label_name in self.label_names)}" + text = f"{attribute_name} has labels {', '.join(repr(label_name) for label_name in self.label_names)}" case "can_input": assert self.enable is not None text = f"{attribute_name} can be edited" if self.enable else f"{attribute_name} is read-only" @@ -1408,19 +1408,6 @@ def _quote_human(value: object) -> str: return f"'{value}'" -def _repr_human_value(value: object) -> str: - """ - 人間向け表示用に値を読みやすく文字列化します。 - - Args: - value: 表示対象の値です。 - - Returns: - 文字列はクォート付き、それ以外は自然な文字列表現です。 - """ - return repr(value) - - def _imply_to_human_readable(ast: RestrictionAst) -> str: """ `imply` AST を自然文スタイルの文字列へ変換します。 From 5f6c06381e50b2e710a26eeb7b3f86897325da70 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 01:35:59 +0900 Subject: [PATCH 17/47] Remove quote_human helper --- annofabapi/util/attribute_restrictions.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index 76ecce8c..017299fb 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -463,7 +463,7 @@ def to_human_readable(self) -> str: return _imply_to_human_readable(self) assert self.attribute_name is not None - attribute_name = _quote_human(self.attribute_name) + attribute_name = repr(self.attribute_name) simple_text_map = { "checked": f"{attribute_name} is checked", "unchecked": f"{attribute_name} is unchecked", @@ -1395,19 +1395,6 @@ def _repr_python_value(value: object) -> str: return repr(value) -def _quote_human(value: object) -> str: - """ - 人間向け表示用に値をシングルクォートで囲みます。 - - Args: - value: 表示対象の値です。 - - Returns: - シングルクォートで囲んだ文字列表現です。 - """ - return f"'{value}'" - - def _imply_to_human_readable(ast: RestrictionAst) -> str: """ `imply` AST を自然文スタイルの文字列へ変換します。 From 00d028a274568941df3245de53b273e163aa3a25 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 01:38:17 +0900 Subject: [PATCH 18/47] Inline imply human-readable helpers --- annofabapi/util/attribute_restrictions.py | 112 +++++++++++----------- 1 file changed, 55 insertions(+), 57 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index 017299fb..918b6a39 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -459,8 +459,62 @@ def to_human_readable(self) -> str: Raises: ValueError: 未知のAST種別が指定された場合 """ + + def flatten_imply_conditions(ast: RestrictionAst) -> tuple[list[RestrictionAst], RestrictionAst]: + """ + 右側にネストした `imply` を条件列と結論へ分解します。 + + Args: + ast: `imply` 種別のASTです。 + + Returns: + 条件ASTの一覧と最終的な結論ASTです。 + """ + assert ast.premise is not None + assert ast.conclusion is not None + + conditions = [ast.premise] + conclusion = ast.conclusion + while conclusion.type == "imply": + assert conclusion.premise is not None + assert conclusion.conclusion is not None + conditions.append(conclusion.premise) + conclusion = conclusion.conclusion + return conditions, conclusion + + def to_human_condition_text(ast: RestrictionAst) -> str: + """ + 条件節で使う人間向け文字列表現へ変換します。 + + Args: + ast: 変換対象のASTです。 + + Returns: + 条件節で使いやすい文字列表現です。 + """ + if ast.type == "imply": + return f"({ast.to_human_readable()})" + return ast.to_human_readable() + + def imply_to_human_readable(ast: RestrictionAst) -> str: + """ + `imply` AST を自然文スタイルの文字列へ変換します。 + + 右側にネストした `imply` は条件を畳み込んで、 + `If A and B, C.` のような形へ変換します。 + + Args: + ast: `imply` 種別のASTです。 + + Returns: + 自然文スタイルの文字列表現です。 + """ + conditions, conclusion = flatten_imply_conditions(ast) + conditions_text = " and ".join(to_human_condition_text(condition) for condition in conditions) + return f"If {conditions_text}, {conclusion.to_human_readable()}." + if self.type == "imply": - return _imply_to_human_readable(self) + return imply_to_human_readable(self) assert self.attribute_name is not None attribute_name = repr(self.attribute_name) @@ -1393,59 +1447,3 @@ def _repr_python_value(value: object) -> str: `repr()` による文字列表現です。 """ return repr(value) - - -def _imply_to_human_readable(ast: RestrictionAst) -> str: - """ - `imply` AST を自然文スタイルの文字列へ変換します。 - - 右側にネストした `imply` は条件を畳み込んで、 - `If A and B, C.` のような形へ変換します。 - - Args: - ast: `imply` 種別のASTです。 - - Returns: - 自然文スタイルの文字列表現です。 - """ - conditions, conclusion = _flatten_imply_conditions(ast) - conditions_text = " and ".join(_to_human_condition_text(condition) for condition in conditions) - return f"If {conditions_text}, {conclusion.to_human_readable()}." - - -def _flatten_imply_conditions(ast: RestrictionAst) -> tuple[list[RestrictionAst], RestrictionAst]: - """ - 右側にネストした `imply` を条件列と結論へ分解します。 - - Args: - ast: `imply` 種別のASTです。 - - Returns: - 条件ASTの一覧と最終的な結論ASTです。 - """ - assert ast.premise is not None - assert ast.conclusion is not None - - conditions = [ast.premise] - conclusion = ast.conclusion - while conclusion.type == "imply": - assert conclusion.premise is not None - assert conclusion.conclusion is not None - conditions.append(conclusion.premise) - conclusion = conclusion.conclusion - return conditions, conclusion - - -def _to_human_condition_text(ast: RestrictionAst) -> str: - """ - 条件節で使う人間向け文字列表現へ変換します。 - - Args: - ast: 変換対象のASTです。 - - Returns: - 条件節で使いやすい文字列表現です。 - """ - if ast.type == "imply": - return f"({ast.to_human_readable()})" - return ast.to_human_readable() From cfda847bee02b4760df333ba01006a23d17fe36d Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 01:44:31 +0900 Subject: [PATCH 19/47] Use assert_noreturn in restriction matches --- annofabapi/util/attribute_restrictions.py | 147 +++++++++++++++------- 1 file changed, 102 insertions(+), 45 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index 918b6a39..49044a21 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -35,6 +35,7 @@ from annofabapi.pydantic_models.additional_data_definition_type import AdditionalDataDefinitionType from annofabapi.util.annotation_specs import AnnotationSpecsAccessor, get_choice, get_english_message +from annofabapi.util.type_util import assert_noreturn RestrictionAstType = Literal[ "checked", @@ -518,37 +519,7 @@ def imply_to_human_readable(ast: RestrictionAst) -> str: assert self.attribute_name is not None attribute_name = repr(self.attribute_name) - simple_text_map = { - "checked": f"{attribute_name} is checked", - "unchecked": f"{attribute_name} is unchecked", - "is_empty": f"{attribute_name} is empty", - "is_not_empty": f"{attribute_name} is not empty", - } - if self.type in simple_text_map: - return simple_text_map[self.type] - - match self.type: - case "equals_string" | "equals_integer": - text = f"{attribute_name} is " + repr(self.value) - case "not_equals_string" | "not_equals_integer": - text = f"{attribute_name} is not " + repr(self.value) - case "matches_string": - text = f"{attribute_name} matches " + repr(self.value) - case "not_matches_string": - text = f"{attribute_name} does not match " + repr(self.value) - case "has_choice": - text = f"{attribute_name} is " + repr(self.choice_name) - case "not_has_choice": - text = f"{attribute_name} is not " + repr(self.choice_name) - case "has_label": - assert self.label_names is not None - text = f"{attribute_name} has labels {', '.join(repr(label_name) for label_name in self.label_names)}" - case "can_input": - assert self.enable is not None - text = f"{attribute_name} can be edited" if self.enable else f"{attribute_name} is read-only" - case _: - raise ValueError(f"未知のAST種別です。 :: type='{self.type}'") - return text + return _restriction_ast_to_human_readable_text(self, attribute_name=attribute_name) RestrictionAst.model_rebuild() @@ -659,8 +630,8 @@ def _get_required_ast_fields(ast_type: RestrictionAstType) -> set[str]: return {"attribute_name", "enable"} case "imply": return {"premise", "conclusion"} - case _: - raise ValueError(f"未知のAST種別です。 :: type='{ast_type}'") + case _ as never: + assert_noreturn(never) def _validate_restriction_ast(ast: RestrictionAst) -> None: @@ -690,6 +661,19 @@ def _validate_restriction_ast(ast: RestrictionAst) -> None: if actual_fields != required_fields: raise ValueError(f"AST種別'{ast.type}'のフィールドが不正です。 :: required={sorted(required_fields)}, actual={sorted(actual_fields)}") + _validate_restriction_ast_field_types(ast) + + +def _validate_restriction_ast_field_types(ast: RestrictionAst) -> None: + """ + `RestrictionAst` の値フィールドがAST種別に整合しているか検証します。 + + Args: + ast: 検証対象のASTです。 + + Raises: + ValueError: AST種別に対して値の型が不正な場合 + """ match ast.type: case "equals_string" | "not_equals_string" | "matches_string" | "not_matches_string": if not isinstance(ast.value, str): @@ -706,11 +690,58 @@ def _validate_restriction_ast(ast: RestrictionAst) -> None: case "can_input": if not isinstance(ast.enable, bool): raise ValueError("AST種別'can_input'の'enable'は真偽値である必要があります。") - case _: + case "checked" | "unchecked" | "is_empty" | "is_not_empty" | "imply": pass + case _ as never: + assert_noreturn(never) + + +def _restriction_ast_to_human_readable_text(ast: RestrictionAst, *, attribute_name: str) -> str: # noqa: PLR0912 + """ + `imply` 以外のASTを人間向けの読みやすい文字列へ変換します。 + Args: + ast: 変換対象のASTです。 + attribute_name: `repr()` 済みの属性名です。 -def _get_allowed_ast_types(attribute_type: str) -> list[RestrictionAstType]: + Returns: + 人間向けの読みやすい文字列です。 + """ + match ast.type: + case "checked": + text = f"{attribute_name} is checked" + case "unchecked": + text = f"{attribute_name} is unchecked" + case "is_empty": + text = f"{attribute_name} is empty" + case "is_not_empty": + text = f"{attribute_name} is not empty" + case "equals_string" | "equals_integer": + text = f"{attribute_name} is " + repr(ast.value) + case "not_equals_string" | "not_equals_integer": + text = f"{attribute_name} is not " + repr(ast.value) + case "matches_string": + text = f"{attribute_name} matches " + repr(ast.value) + case "not_matches_string": + text = f"{attribute_name} does not match " + repr(ast.value) + case "has_choice": + text = f"{attribute_name} is " + repr(ast.choice_name) + case "not_has_choice": + text = f"{attribute_name} is not " + repr(ast.choice_name) + case "has_label": + assert ast.label_names is not None + text = f"{attribute_name} has labels {', '.join(repr(label_name) for label_name in ast.label_names)}" + case "can_input": + assert ast.enable is not None + text = f"{attribute_name} can be edited" if ast.enable else f"{attribute_name} is read-only" + case "imply": + raise AssertionError("`imply`は事前に処理されるため、ここには到達しません。") + case _ as never: + assert_noreturn(never) + return text + + +def _get_allowed_ast_types(attribute_type: AdditionalDataDefinitionType) -> list[RestrictionAstType]: """ 属性種類ごとに利用可能なAST種別を返します。 @@ -963,8 +994,10 @@ def _ast_to_atomic_restriction(ast: RestrictionAst, *, fac: AttributeFactory, at restriction = _ast_selection_to_restriction(ast=ast, fac=fac) case "has_label": restriction = _ast_label_to_restriction(ast=ast, fac=fac) - case _: - raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") + case "imply": + raise AssertionError("`imply`は `_ast_to_restriction` で処理されるため、ここには到達しません。") + case _ as never: + assert_noreturn(never) return restriction @@ -973,7 +1006,7 @@ def _ast_string_equality_to_restriction( ast: RestrictionAst, fac: AttributeFactory, attribute: dict[str, Any], - attribute_type: str, + attribute_type: AdditionalDataDefinitionType, ) -> Restriction: assert isinstance(ast.value, str) attribute_obj: StringTextbox | TrackingId @@ -999,7 +1032,7 @@ def _ast_string_match_to_restriction( ast: RestrictionAst, fac: AttributeFactory, attribute: dict[str, Any], - attribute_type: str, + attribute_type: AdditionalDataDefinitionType, ) -> Restriction: assert isinstance(ast.value, str) if attribute_type not in {"text", "comment"}: @@ -1059,7 +1092,7 @@ def _create_attribute_object(fac: AttributeFactory, attribute: dict[str, Any]) - ValueError: 未対応の属性種類が指定された場合 """ attribute_id = attribute["additional_data_definition_id"] - attribute_type = attribute["type"] + attribute_type: AdditionalDataDefinitionType = attribute["type"] match attribute_type: case "flag": return fac.checkbox(attribute_id=attribute_id) @@ -1145,7 +1178,13 @@ def _restriction_to_atomic_ast( raise ValueError(f"RestrictionをASTへ変換できません。 :: restriction={restriction.to_dict()}") -def _equals_restriction_to_ast(*, attribute: dict[str, Any], attribute_name: str, attribute_type: str, value: str) -> RestrictionAst: +def _equals_restriction_to_ast( + *, + attribute: dict[str, Any], + attribute_name: str, + attribute_type: AdditionalDataDefinitionType, + value: str, +) -> RestrictionAst: match attribute_type: case "flag" if value == "true": return RestrictionAst(type="checked", attribute_name=attribute_name) @@ -1162,7 +1201,13 @@ def _equals_restriction_to_ast(*, attribute: dict[str, Any], attribute_name: str raise ValueError(f"RestrictionをASTへ変換できません。 :: restriction_type='Equals', attribute_type='{attribute_type}', value={value!r}") -def _not_equals_restriction_to_ast(*, attribute: dict[str, Any], attribute_name: str, attribute_type: str, value: str) -> RestrictionAst: +def _not_equals_restriction_to_ast( + *, + attribute: dict[str, Any], + attribute_name: str, + attribute_type: AdditionalDataDefinitionType, + value: str, +) -> RestrictionAst: match attribute_type: case "flag" if value == "true": return RestrictionAst(type="unchecked", attribute_name=attribute_name) @@ -1347,7 +1392,13 @@ def _restriction_to_atomic_python_expr( raise ValueError(f"Restrictionを高水準APIのPython式へ変換できません。 :: restriction={restriction.to_dict()}") -def _equals_restriction_to_python_expr(*, attribute: dict[str, Any], attribute_expr: str, attribute_type: str, value: str) -> str: +def _equals_restriction_to_python_expr( + *, + attribute: dict[str, Any], + attribute_expr: str, + attribute_type: AdditionalDataDefinitionType, + value: str, +) -> str: if value == "": match attribute_type: case "text" | "comment" | "integer" | "link" | "tracking" | "choice" | "select": @@ -1373,7 +1424,13 @@ def _equals_restriction_to_python_expr(*, attribute: dict[str, Any], attribute_e return expr -def _not_equals_restriction_to_python_expr(*, attribute: dict[str, Any], attribute_expr: str, attribute_type: str, value: str) -> str: +def _not_equals_restriction_to_python_expr( + *, + attribute: dict[str, Any], + attribute_expr: str, + attribute_type: AdditionalDataDefinitionType, + value: str, +) -> str: if value == "": match attribute_type: case "text" | "comment" | "integer" | "link" | "tracking" | "choice" | "select": @@ -1415,7 +1472,7 @@ def _attribute_to_python_expr(attribute: dict[str, Any], *, factory_name: str) - ValueError: 未対応の属性種類が指定された場合 """ attribute_name = get_english_message(attribute["name"]) - attribute_type = attribute["type"] + attribute_type: AdditionalDataDefinitionType = attribute["type"] match attribute_type: case "flag": From a911e072364c32fc007d29f84008757021b27f12 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 01:51:41 +0900 Subject: [PATCH 20/47] Remove annotation_specs from Restriction.from_dict --- annofabapi/util/attribute_restrictions.py | 184 +++------------------- tests/util/test_attribute_restrictions.py | 30 ++-- 2 files changed, 36 insertions(+), 178 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index 49044a21..5b796d0f 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -72,22 +72,20 @@ def to_dict(self) -> dict[str, Any]: return {"additional_data_definition_id": self.attribute_id, "condition": self._to_dict_only_condition()} @classmethod - def from_dict(cls, obj: dict[str, Any], annotation_specs: dict[str, Any] | None = None) -> "Restriction": + def from_dict(cls, obj: dict[str, Any]) -> "Restriction": """ dictからRestrictionオブジェクトを復元します。 Args: obj: `restrictions` の1要素を表す辞書です。 - annotation_specs: Noneでなければ、アノテーション仕様を用いて属性型ごとの妥当性を検証します。 Returns: 復元した `Restriction` オブジェクトです。 Raises: - ValueError: 制約の形式が不正な場合、または `annotation_specs` を使った妥当性検証に失敗した場合 + ValueError: 制約の形式が不正な場合 """ - fac = AttributeFactory(annotation_specs) if annotation_specs is not None else None - return _from_restriction_dict(obj, fac=fac) + return _from_restriction_dict(obj) def to_python_expr(self, annotation_specs: dict[str, Any], *, factory_name: str = "fac") -> str: """ @@ -588,20 +586,18 @@ def get_attribute_restriction_catalog(annotation_specs: dict[str, Any]) -> list[ return catalog -def _from_restriction_dict(obj: dict[str, Any], *, fac: AttributeFactory | None) -> Restriction: +def _from_restriction_dict(obj: dict[str, Any]) -> Restriction: """ API向けの制約辞書から `Restriction` を復元します。 Args: obj: APIの `restrictions` 要素を表す辞書です。 - fac: Noneでなければ、属性型に応じた妥当性検証に使う `AttributeFactory` です。 - Returns: 復元した `Restriction` オブジェクトです。 """ attribute_id = obj["additional_data_definition_id"] condition = obj["condition"] - return _from_condition_dict(attribute_id=attribute_id, condition=condition, fac=fac) + return _from_condition_dict(attribute_id=attribute_id, condition=condition) def _get_required_ast_fields(ast_type: RestrictionAstType) -> set[str]: @@ -771,33 +767,29 @@ def _get_allowed_ast_types(attribute_type: AdditionalDataDefinitionType) -> list raise ValueError(f"未対応の属性種類です。 :: attribute_type='{attribute_type}'") -def _from_condition_dict(*, attribute_id: str, condition: dict[str, Any], fac: AttributeFactory | None) -> Restriction: +def _from_condition_dict(*, attribute_id: str, condition: dict[str, Any]) -> Restriction: """ 条件部分の辞書から `Restriction` を復元します。 Args: attribute_id: 対象属性のIDです。 condition: 条件部分のみを表す辞書です。 - fac: Noneでなければ、属性型に応じた妥当性検証に使う `AttributeFactory` です。 - Returns: 復元した `Restriction` オブジェクトです。 """ condition_type = condition["_type"] match condition_type: case "Imply": - premise_restriction = _from_restriction_dict(condition["premise"], fac=fac) - conclusion_restriction = _from_condition_dict(attribute_id=attribute_id, condition=condition["condition"], fac=fac) + premise_restriction = _from_restriction_dict(condition["premise"]) + conclusion_restriction = _from_condition_dict(attribute_id=attribute_id, condition=condition["condition"]) return Imply(premise_restriction=premise_restriction, conclusion_restriction=conclusion_restriction) - case _ if fac is None: - return _from_condition_dict_without_validation(attribute_id=attribute_id, condition=condition) case _: - return _from_condition_dict_with_validation(attribute_id=attribute_id, condition=condition, fac=fac) + return _from_atomic_condition_dict(attribute_id=attribute_id, condition=condition) -def _from_condition_dict_without_validation(*, attribute_id: str, condition: dict[str, Any]) -> Restriction: +def _from_atomic_condition_dict(*, attribute_id: str, condition: dict[str, Any]) -> Restriction: """ - 妥当性検証を行わずに条件辞書から `Restriction` を復元します。 + 原子的な条件辞書から `Restriction` を復元します。 Args: attribute_id: 対象属性のIDです。 @@ -827,146 +819,6 @@ def _from_condition_dict_without_validation(*, attribute_id: str, condition: dic raise ValueError(f"未知の制約種別です。 :: _type='{condition_type}'") -def _from_condition_dict_with_validation(*, attribute_id: str, condition: dict[str, Any], fac: AttributeFactory) -> Restriction: - """ - 属性型の妥当性を検証しながら条件辞書から `Restriction` を復元します。 - - Args: - attribute_id: 対象属性のIDです。 - condition: 条件部分のみを表す辞書です。 - fac: 属性生成と妥当性検証に使う `AttributeFactory` です。 - - Returns: - 復元した `Restriction` オブジェクトです。 - - Raises: - ValueError: 属性型に対して許可されていない制約が指定された場合 - """ - attribute = fac.accessor.get_attribute(attribute_id=attribute_id) - attribute_obj = _create_attribute_object(fac, attribute) - attribute_type = attribute["type"] - condition_type = condition["_type"] - - match condition_type: - case "CanInput": - return attribute_obj.enabled() if condition["enable"] else attribute_obj.disabled() - case _: - return _from_condition_dict_for_attribute_type( - attribute=attribute, - attribute_obj=attribute_obj, - condition=condition, - attribute_type=attribute_type, - ) - - -def _from_condition_dict_for_attribute_type( - *, - attribute: dict[str, Any], - attribute_obj: Attribute, - condition: dict[str, Any], - attribute_type: str, -) -> Restriction: - match attribute_type: - case "flag": - assert isinstance(attribute_obj, Checkbox) - return _from_flag_condition(attribute=attribute, attribute_obj=attribute_obj, condition=condition) - case "text" | "comment": - assert isinstance(attribute_obj, StringTextbox) - return _from_string_condition(attribute=attribute, attribute_obj=attribute_obj, condition=condition) - case "integer": - assert isinstance(attribute_obj, IntegerTextbox) - return _from_integer_condition(attribute=attribute, attribute_obj=attribute_obj, condition=condition) - case "link": - assert isinstance(attribute_obj, AnnotationLink) - return _from_link_condition(attribute=attribute, attribute_obj=attribute_obj, condition=condition) - case "tracking": - assert isinstance(attribute_obj, TrackingId) - return _from_tracking_condition(attribute=attribute, attribute_obj=attribute_obj, condition=condition) - case "choice" | "select": - assert isinstance(attribute_obj, Selection) - return _from_selection_condition(attribute=attribute, attribute_obj=attribute_obj, condition=condition) - case _: - raise ValueError(f"未対応の属性種類です。 :: attribute_type='{attribute_type}'") - - -def _from_flag_condition(*, attribute: dict[str, Any], attribute_obj: Checkbox, condition: dict[str, Any]) -> Restriction: - match condition["_type"]: - case "Equals" if condition["value"] == "true": - return attribute_obj.checked() - case "NotEquals" if condition["value"] == "true": - return attribute_obj.unchecked() - case _: - _raise_invalid_restriction(attribute=attribute, condition=condition) - - -def _from_string_condition(*, attribute: dict[str, Any], attribute_obj: StringTextbox, condition: dict[str, Any]) -> Restriction: - match condition["_type"]: - case "Equals": - return attribute_obj.equals(condition["value"]) - case "NotEquals": - return attribute_obj.not_equals(condition["value"]) - case "Matches": - return attribute_obj.matches(condition["value"]) - case "NotMatches": - return attribute_obj.not_matches(condition["value"]) - case _: - _raise_invalid_restriction(attribute=attribute, condition=condition) - - -def _from_integer_condition(*, attribute: dict[str, Any], attribute_obj: IntegerTextbox, condition: dict[str, Any]) -> Restriction: - match condition["_type"]: - case "Equals": - if condition["value"] == "": - return attribute_obj.is_empty() - return attribute_obj.equals(_parse_integer_value(condition["value"], attribute=attribute, condition=condition)) - case "NotEquals": - if condition["value"] == "": - return attribute_obj.is_not_empty() - return attribute_obj.not_equals(_parse_integer_value(condition["value"], attribute=attribute, condition=condition)) - case _: - _raise_invalid_restriction(attribute=attribute, condition=condition) - - -def _from_link_condition(*, attribute: dict[str, Any], attribute_obj: AnnotationLink, condition: dict[str, Any]) -> Restriction: - match condition["_type"]: - case "HasLabel": - return attribute_obj.has_label(label_ids=condition["labels"]) - case "Equals" if condition["value"] == "": - return attribute_obj.is_empty() - case "NotEquals" if condition["value"] == "": - return attribute_obj.is_not_empty() - case _: - _raise_invalid_restriction(attribute=attribute, condition=condition) - - -def _from_tracking_condition(*, attribute: dict[str, Any], attribute_obj: TrackingId, condition: dict[str, Any]) -> Restriction: - match condition["_type"]: - case "Equals": - if condition["value"] == "": - return attribute_obj.is_empty() - return attribute_obj.equals(condition["value"]) - case "NotEquals": - if condition["value"] == "": - return attribute_obj.is_not_empty() - return attribute_obj.not_equals(condition["value"]) - case _: - _raise_invalid_restriction(attribute=attribute, condition=condition) - - -def _from_selection_condition(*, attribute: dict[str, Any], attribute_obj: Selection, condition: dict[str, Any]) -> Restriction: - match condition["_type"]: - case "Equals": - if condition["value"] == "": - return attribute_obj.is_empty() - return attribute_obj.has_choice(choice_id=condition["value"]) - case "NotEquals": - if condition["value"] == "": - return attribute_obj.is_not_empty() - return attribute_obj.not_has_choice(choice_id=condition["value"]) - case _: - _raise_invalid_restriction(attribute=attribute, condition=condition) - - def _ast_to_atomic_restriction(ast: RestrictionAst, *, fac: AttributeFactory, attribute: dict[str, Any]) -> Restriction: assert ast.attribute_name is not None attribute_type = attribute["type"] @@ -1175,7 +1027,7 @@ def _restriction_to_atomic_ast( label_names = [get_english_message(accessor.get_label(label_id=label_id)["label_name"]) for label_id in label_ids] return RestrictionAst(type="has_label", attribute_name=attribute_name, label_names=label_names) case _: - raise ValueError(f"RestrictionをASTへ変換できません。 :: restriction={restriction.to_dict()}") + _raise_invalid_restriction(attribute=attribute, condition=restriction.to_dict()["condition"]) def _equals_restriction_to_ast( @@ -1193,7 +1045,11 @@ def _equals_restriction_to_ast( case "text" | "comment" | "tracking": return RestrictionAst(type="equals_string", attribute_name=attribute_name, value=value) case "integer": - return RestrictionAst(type="equals_integer", attribute_name=attribute_name, value=int(value)) + return RestrictionAst( + type="equals_integer", + attribute_name=attribute_name, + value=_parse_integer_value(value, attribute=attribute, condition={"_type": "Equals", "value": value}), + ) case "choice" | "select": choice = get_choice(attribute["choices"], choice_id=value) return RestrictionAst(type="has_choice", attribute_name=attribute_name, choice_name=get_english_message(choice["name"])) @@ -1216,7 +1072,11 @@ def _not_equals_restriction_to_ast( case "text" | "comment" | "tracking": return RestrictionAst(type="not_equals_string", attribute_name=attribute_name, value=value) case "integer": - return RestrictionAst(type="not_equals_integer", attribute_name=attribute_name, value=int(value)) + return RestrictionAst( + type="not_equals_integer", + attribute_name=attribute_name, + value=_parse_integer_value(value, attribute=attribute, condition={"_type": "NotEquals", "value": value}), + ) case "choice" | "select": choice = get_choice(attribute["choices"], choice_id=value) return RestrictionAst(type="not_has_choice", attribute_name=attribute_name, choice_name=get_english_message(choice["name"])) diff --git a/tests/util/test_attribute_restrictions.py b/tests/util/test_attribute_restrictions.py index e7b52242..44d281e7 100644 --- a/tests/util/test_attribute_restrictions.py +++ b/tests/util/test_attribute_restrictions.py @@ -226,7 +226,7 @@ def test__from_dict(self): }, } - actual = Restriction.from_dict(restriction_dict, annotation_specs=accessor.annotation_specs) + actual = Restriction.from_dict(restriction_dict) assert actual.to_dict() == restriction_dict @@ -242,7 +242,7 @@ def test__to_python_expr(self): "condition": {"_type": "NotEquals", "value": ""}, }, } - restriction = Restriction.from_dict(restriction_dict, annotation_specs=accessor.annotation_specs) + restriction = Restriction.from_dict(restriction_dict) actual = restriction.to_python_expr(accessor.annotation_specs) @@ -253,8 +253,7 @@ def test__to_python_expr__selection(self): { "additional_data_definition_id": "cbb0155f-1631-48e1-8fc3-43c5f254b6f2", "condition": {"_type": "NotEquals", "value": "7512ee39-8073-4e24-9b8c-93d99b76b7d2"}, - }, - annotation_specs=accessor.annotation_specs, + } ) actual = restriction.to_python_expr(accessor.annotation_specs) @@ -273,8 +272,7 @@ def test__to_ast(self): }, "condition": {"_type": "NotEquals", "value": ""}, }, - }, - annotation_specs=accessor.annotation_specs, + } ) actual = restriction.to_ast(accessor.annotation_specs) @@ -297,8 +295,7 @@ def test__to_human_readable(self): }, "condition": {"_type": "NotEquals", "value": ""}, }, - }, - annotation_specs=accessor.annotation_specs, + } ) actual = restriction.to_human_readable(accessor.annotation_specs) @@ -324,15 +321,14 @@ def test__to_human_readable__右側にネストしたimplyは条件をまとめ "condition": {"_type": "NotEquals", "value": ""}, }, }, - }, - annotation_specs=accessor.annotation_specs, + } ) actual = restriction.to_human_readable(accessor.annotation_specs) assert actual == "If 'occluded' is checked and 'traffic_lane' is 2, 'note' is not empty." - def test__from_dict__annotation_specsを指定しない場合は妥当性検証しない(self): + def test__from_dict__妥当性検証せずに復元する(self): restriction_dict = { "additional_data_definition_id": "d349e76d-b59a-44cd-94b4-713a00b2e84d", "condition": {"_type": "Matches", "value": "\\d+"}, @@ -342,30 +338,32 @@ def test__from_dict__annotation_specsを指定しない場合は妥当性検証 assert actual.to_dict() == restriction_dict - def test__from_dict__tracking_id属性にmatchesは指定できない(self): + def test__to_ast__tracking_id属性にmatchesは指定できない(self): restriction_dict = { "additional_data_definition_id": "d349e76d-b59a-44cd-94b4-713a00b2e84d", "condition": {"_type": "Matches", "value": "\\d+"}, } + restriction = Restriction.from_dict(restriction_dict) with pytest.raises(ValueError, match="属性'tracking'\\(type='tracking'\\)では制約'Matches'を利用できません。"): - Restriction.from_dict(restriction_dict, annotation_specs=accessor.annotation_specs) + restriction.to_ast(accessor.annotation_specs) - def test__from_dict__integer属性に整数以外の値は指定できない(self): + def test__to_ast__integer属性に整数以外の値は指定できない(self): restriction_dict = { "additional_data_definition_id": "ec27de5d-122c-40e7-89bc-5500e37bae6a", "condition": {"_type": "Equals", "value": "foo"}, } + restriction = Restriction.from_dict(restriction_dict) with pytest.raises(ValueError, match="整数属性には整数値を指定してください。"): - Restriction.from_dict(restriction_dict, annotation_specs=accessor.annotation_specs) + restriction.to_ast(accessor.annotation_specs) def test__from_dict__can_input_true(self): restriction_dict = { "additional_data_definition_id": "2517f635-2269-4142-8ef4-16312b4cc9f7", "condition": {"_type": "CanInput", "enable": True}, } - restriction = Restriction.from_dict(restriction_dict, annotation_specs=accessor.annotation_specs) + restriction = Restriction.from_dict(restriction_dict) assert restriction.to_dict() == restriction_dict assert restriction.to_python_expr(accessor.annotation_specs) == "fac.checkbox(attribute_name='occluded').enabled()" From ac792cc830aa1ea6a6adaed473a516868c6794a2 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 01:59:29 +0900 Subject: [PATCH 21/47] =?UTF-8?q?SKILL.md=E3=81=ABassert=5Fnoreturn?= =?UTF-8?q?=E3=81=AE=E4=BD=BF=E7=94=A8=E3=81=AB=E9=96=A2=E3=81=99=E3=82=8B?= =?UTF-8?q?=E3=82=AC=E3=82=A4=E3=83=89=E3=83=A9=E3=82=A4=E3=83=B3=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=20AGENTS.md=E3=81=AB=E3=82=B3=E3=83=BC?= =?UTF-8?q?=E3=83=89=E4=BF=AE=E6=AD=A3=E6=96=B9=E9=87=9D=E3=81=AB=E9=96=A2?= =?UTF-8?q?=E3=81=99=E3=82=8B=E8=AA=AC=E6=98=8E=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- .agents/skills/python-coding-style/SKILL.md | 1 + AGENTS.md | 1 + 2 files changed, 2 insertions(+) diff --git a/.agents/skills/python-coding-style/SKILL.md b/.agents/skills/python-coding-style/SKILL.md index c97856cc..909522ad 100644 --- a/.agents/skills/python-coding-style/SKILL.md +++ b/.agents/skills/python-coding-style/SKILL.md @@ -13,4 +13,5 @@ description: Pythonコードを作成・修正するときに使用。 * モジュールレベルの定数、クラス属性、インスタンス属性などには直後に docstring として記述する。VSCodeのtooltipに表示させるため。 * dictから値を取得する際、必須なキーならばブラケット記法を使う。キーが必須がどうか分からない場合は、必須とみなす。 * match文が利用できる箇所では、if文よりもmatch文を使用する。 + * 必要ならば `annofabapi.util.type_util.assert_noreturn` を使用して、match文の網羅性を保証する。 diff --git a/AGENTS.md b/AGENTS.md index 5654b59c..86eab9f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,7 @@ ## コードの修正方針 * 原則、破壊的変更を行って修正してください。コードをシンプルにするためです。 +* 何かを判断する際、コードの修正量は無視してください。AIが修正するので、そこは問題になりません。 ## Coding Agent による作業の進め方 1. コードを修正する。関連するテストコードやドキュメントも修正する。 From c4eccf961fbd2c8c39cd31acb93e65ce232fed87 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 02:02:32 +0900 Subject: [PATCH 22/47] Refactor restriction AST types to enum --- annofabapi/util/attribute_restrictions.py | 310 ++++++++++++++-------- tests/util/test_attribute_restrictions.py | 35 +-- 2 files changed, 219 insertions(+), 126 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index 5b796d0f..160b78aa 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -29,31 +29,37 @@ from abc import ABC, abstractmethod from collections.abc import Collection -from typing import Any, Literal, NoReturn +from enum import Enum +from typing import Any, NoReturn -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_serializer, model_validator from annofabapi.pydantic_models.additional_data_definition_type import AdditionalDataDefinitionType -from annofabapi.util.annotation_specs import AnnotationSpecsAccessor, get_choice, get_english_message +from annofabapi.util.annotation_specs import AnnotationSpecsAccessor, AttributeChoice, AttributeDefinition, get_choice, get_english_message from annofabapi.util.type_util import assert_noreturn -RestrictionAstType = Literal[ - "checked", - "unchecked", - "is_empty", - "is_not_empty", - "equals_string", - "not_equals_string", - "matches_string", - "not_matches_string", - "equals_integer", - "not_equals_integer", - "has_choice", - "not_has_choice", - "has_label", - "can_input", - "imply", -] + +class RestrictionAstType(str, Enum): + """属性制約ASTの種別です。""" + + CHECKED = "checked" + UNCHECKED = "unchecked" + IS_EMPTY = "is_empty" + IS_NOT_EMPTY = "is_not_empty" + EQUALS_STRING = "equals_string" + NOT_EQUALS_STRING = "not_equals_string" + MATCHES_STRING = "matches_string" + NOT_MATCHES_STRING = "not_matches_string" + EQUALS_INTEGER = "equals_integer" + NOT_EQUALS_INTEGER = "not_equals_integer" + HAS_CHOICE = "has_choice" + NOT_HAS_CHOICE = "not_has_choice" + HAS_LABEL = "has_label" + CAN_INPUT = "can_input" + IMPLY = "imply" + + def __str__(self) -> str: + return self.value class Restriction(ABC): @@ -360,13 +366,13 @@ def _is_valid_attribute_type(self) -> bool: def has_choice(self, *, choice_id: str | None = None, choice_name: str | None = None) -> Restriction: """引数`choice_id`または`choice_name`に一致する選択肢が選択されているという制約""" - choices = self.attribute["choices"] + choices = _get_attribute_choices(self.attribute) 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: str | None = None, choice_name: str | None = None) -> Restriction: """引数`choice_id`または`choice_name`に一致する選択肢が選択されていないという制約""" - choices = self.attribute["choices"] + choices = _get_attribute_choices(self.attribute) choice = get_choice(choices, choice_id=choice_id, choice_name=choice_name) return NotEquals(self.attribute_id, choice["choice_id"]) @@ -435,6 +441,10 @@ def validate_restriction_ast(self) -> "RestrictionAst": _validate_restriction_ast(self) return self + @field_serializer("type") + def serialize_type(self, ast_type: RestrictionAstType) -> str: + return ast_type.value + def to_restriction(self, annotation_specs: dict[str, Any]) -> Restriction: """ ASTをRestrictionオブジェクトへコンパイルします。 @@ -474,7 +484,7 @@ def flatten_imply_conditions(ast: RestrictionAst) -> tuple[list[RestrictionAst], conditions = [ast.premise] conclusion = ast.conclusion - while conclusion.type == "imply": + while conclusion.type == RestrictionAstType.IMPLY: assert conclusion.premise is not None assert conclusion.conclusion is not None conditions.append(conclusion.premise) @@ -491,7 +501,7 @@ def to_human_condition_text(ast: RestrictionAst) -> str: Returns: 条件節で使いやすい文字列表現です。 """ - if ast.type == "imply": + if ast.type == RestrictionAstType.IMPLY: return f"({ast.to_human_readable()})" return ast.to_human_readable() @@ -512,7 +522,7 @@ def imply_to_human_readable(ast: RestrictionAst) -> str: conditions_text = " and ".join(to_human_condition_text(condition) for condition in conditions) return f"If {conditions_text}, {conclusion.to_human_readable()}." - if self.type == "imply": + if self.type == RestrictionAstType.IMPLY: return imply_to_human_readable(self) assert self.attribute_name is not None @@ -553,6 +563,14 @@ class AttributeRestrictionCatalogItem(BaseModel): description="link 属性で利用できるラベル名の一覧です。それ以外の属性では null です。", ) + @field_serializer("attribute_type") + def serialize_attribute_type(self, attribute_type: AdditionalDataDefinitionType) -> str: + return attribute_type.value + + @field_serializer("allowed_ast_types") + def serialize_allowed_ast_types(self, allowed_ast_types: list[RestrictionAstType]) -> list[str]: + return [ast_type.value for ast_type in allowed_ast_types] + def get_attribute_restriction_catalog(annotation_specs: dict[str, Any]) -> list[AttributeRestrictionCatalogItem]: """ @@ -572,7 +590,7 @@ def get_attribute_restriction_catalog(annotation_specs: dict[str, Any]) -> list[ label_names = None match attribute_type: case "choice" | "select": - choice_names = [get_english_message(choice["name"]) for choice in attribute["choices"]] + choice_names = [get_english_message(choice["name"]) for choice in _get_attribute_choices(attribute)] case "link": label_names = [get_english_message(label["label_name"]) for label in accessor.labels] item = AttributeRestrictionCatalogItem( @@ -586,6 +604,25 @@ def get_attribute_restriction_catalog(annotation_specs: dict[str, Any]) -> list[ return catalog +def _get_attribute_choices(attribute: AttributeDefinition) -> list[AttributeChoice]: + """ + 属性定義から選択肢一覧を取得します。 + + Args: + attribute: アノテーション仕様上の属性定義です。 + + Returns: + 属性に紐づく選択肢一覧です。 + + Raises: + ValueError: 選択肢を持たない属性に対して呼び出された場合 + """ + choices = attribute["choices"] + if choices is None: + raise ValueError(f"属性(type='{attribute['type']}')には選択肢がありません。") + return choices + + def _from_restriction_dict(obj: dict[str, Any]) -> Restriction: """ API向けの制約辞書から `Restriction` を復元します。 @@ -614,17 +651,24 @@ def _get_required_ast_fields(ast_type: RestrictionAstType) -> set[str]: ValueError: 未知のAST種別が指定された場合 """ match ast_type: - case "checked" | "unchecked" | "is_empty" | "is_not_empty": + case RestrictionAstType.CHECKED | RestrictionAstType.UNCHECKED | RestrictionAstType.IS_EMPTY | RestrictionAstType.IS_NOT_EMPTY: return {"attribute_name"} - case "equals_string" | "not_equals_string" | "matches_string" | "not_matches_string" | "equals_integer" | "not_equals_integer": + case ( + RestrictionAstType.EQUALS_STRING + | RestrictionAstType.NOT_EQUALS_STRING + | RestrictionAstType.MATCHES_STRING + | RestrictionAstType.NOT_MATCHES_STRING + | RestrictionAstType.EQUALS_INTEGER + | RestrictionAstType.NOT_EQUALS_INTEGER + ): return {"attribute_name", "value"} - case "has_choice" | "not_has_choice": + case RestrictionAstType.HAS_CHOICE | RestrictionAstType.NOT_HAS_CHOICE: return {"attribute_name", "choice_name"} - case "has_label": + case RestrictionAstType.HAS_LABEL: return {"attribute_name", "label_names"} - case "can_input": + case RestrictionAstType.CAN_INPUT: return {"attribute_name", "enable"} - case "imply": + case RestrictionAstType.IMPLY: return {"premise", "conclusion"} case _ as never: assert_noreturn(never) @@ -671,22 +715,33 @@ def _validate_restriction_ast_field_types(ast: RestrictionAst) -> None: ValueError: AST種別に対して値の型が不正な場合 """ match ast.type: - case "equals_string" | "not_equals_string" | "matches_string" | "not_matches_string": + case ( + RestrictionAstType.EQUALS_STRING + | RestrictionAstType.NOT_EQUALS_STRING + | RestrictionAstType.MATCHES_STRING + | RestrictionAstType.NOT_MATCHES_STRING + ): if not isinstance(ast.value, str): raise ValueError(f"AST種別'{ast.type}'の'value'は文字列である必要があります。") - case "equals_integer" | "not_equals_integer": + case RestrictionAstType.EQUALS_INTEGER | RestrictionAstType.NOT_EQUALS_INTEGER: if not isinstance(ast.value, int): raise ValueError(f"AST種別'{ast.type}'の'value'は整数である必要があります。") - case "has_choice" | "not_has_choice": + case RestrictionAstType.HAS_CHOICE | RestrictionAstType.NOT_HAS_CHOICE: if not isinstance(ast.choice_name, str): raise ValueError(f"AST種別'{ast.type}'の'choice_name'は文字列である必要があります。") - case "has_label": + case RestrictionAstType.HAS_LABEL: if not isinstance(ast.label_names, list) or any(not isinstance(label_name, str) for label_name in ast.label_names): raise ValueError("AST種別'has_label'の'label_names'は文字列のリストである必要があります。") - case "can_input": + case RestrictionAstType.CAN_INPUT: if not isinstance(ast.enable, bool): raise ValueError("AST種別'can_input'の'enable'は真偽値である必要があります。") - case "checked" | "unchecked" | "is_empty" | "is_not_empty" | "imply": + case ( + RestrictionAstType.CHECKED + | RestrictionAstType.UNCHECKED + | RestrictionAstType.IS_EMPTY + | RestrictionAstType.IS_NOT_EMPTY + | RestrictionAstType.IMPLY + ): pass case _ as never: assert_noreturn(never) @@ -704,33 +759,33 @@ def _restriction_ast_to_human_readable_text(ast: RestrictionAst, *, attribute_na 人間向けの読みやすい文字列です。 """ match ast.type: - case "checked": + case RestrictionAstType.CHECKED: text = f"{attribute_name} is checked" - case "unchecked": + case RestrictionAstType.UNCHECKED: text = f"{attribute_name} is unchecked" - case "is_empty": + case RestrictionAstType.IS_EMPTY: text = f"{attribute_name} is empty" - case "is_not_empty": + case RestrictionAstType.IS_NOT_EMPTY: text = f"{attribute_name} is not empty" - case "equals_string" | "equals_integer": + case RestrictionAstType.EQUALS_STRING | RestrictionAstType.EQUALS_INTEGER: text = f"{attribute_name} is " + repr(ast.value) - case "not_equals_string" | "not_equals_integer": + case RestrictionAstType.NOT_EQUALS_STRING | RestrictionAstType.NOT_EQUALS_INTEGER: text = f"{attribute_name} is not " + repr(ast.value) - case "matches_string": + case RestrictionAstType.MATCHES_STRING: text = f"{attribute_name} matches " + repr(ast.value) - case "not_matches_string": + case RestrictionAstType.NOT_MATCHES_STRING: text = f"{attribute_name} does not match " + repr(ast.value) - case "has_choice": + case RestrictionAstType.HAS_CHOICE: text = f"{attribute_name} is " + repr(ast.choice_name) - case "not_has_choice": + case RestrictionAstType.NOT_HAS_CHOICE: text = f"{attribute_name} is not " + repr(ast.choice_name) - case "has_label": + case RestrictionAstType.HAS_LABEL: assert ast.label_names is not None text = f"{attribute_name} has labels {', '.join(repr(label_name) for label_name in ast.label_names)}" - case "can_input": + case RestrictionAstType.CAN_INPUT: assert ast.enable is not None text = f"{attribute_name} can be edited" if ast.enable else f"{attribute_name} is read-only" - case "imply": + case RestrictionAstType.IMPLY: raise AssertionError("`imply`は事前に処理されるため、ここには到達しません。") case _ as never: assert_noreturn(never) @@ -752,17 +807,48 @@ def _get_allowed_ast_types(attribute_type: AdditionalDataDefinitionType) -> list """ match attribute_type: case "flag": - return ["can_input", "checked", "unchecked"] + return [RestrictionAstType.CAN_INPUT, RestrictionAstType.CHECKED, RestrictionAstType.UNCHECKED] case "text" | "comment": - return ["can_input", "is_empty", "is_not_empty", "equals_string", "not_equals_string", "matches_string", "not_matches_string"] + return [ + RestrictionAstType.CAN_INPUT, + RestrictionAstType.IS_EMPTY, + RestrictionAstType.IS_NOT_EMPTY, + RestrictionAstType.EQUALS_STRING, + RestrictionAstType.NOT_EQUALS_STRING, + RestrictionAstType.MATCHES_STRING, + RestrictionAstType.NOT_MATCHES_STRING, + ] case "integer": - return ["can_input", "is_empty", "is_not_empty", "equals_integer", "not_equals_integer"] + return [ + RestrictionAstType.CAN_INPUT, + RestrictionAstType.IS_EMPTY, + RestrictionAstType.IS_NOT_EMPTY, + RestrictionAstType.EQUALS_INTEGER, + RestrictionAstType.NOT_EQUALS_INTEGER, + ] case "link": - return ["can_input", "is_empty", "is_not_empty", "has_label"] + return [ + RestrictionAstType.CAN_INPUT, + RestrictionAstType.IS_EMPTY, + RestrictionAstType.IS_NOT_EMPTY, + RestrictionAstType.HAS_LABEL, + ] case "tracking": - return ["can_input", "is_empty", "is_not_empty", "equals_string", "not_equals_string"] + return [ + RestrictionAstType.CAN_INPUT, + RestrictionAstType.IS_EMPTY, + RestrictionAstType.IS_NOT_EMPTY, + RestrictionAstType.EQUALS_STRING, + RestrictionAstType.NOT_EQUALS_STRING, + ] case "choice" | "select": - return ["can_input", "is_empty", "is_not_empty", "has_choice", "not_has_choice"] + return [ + RestrictionAstType.CAN_INPUT, + RestrictionAstType.IS_EMPTY, + RestrictionAstType.IS_NOT_EMPTY, + RestrictionAstType.HAS_CHOICE, + RestrictionAstType.NOT_HAS_CHOICE, + ] case _: raise ValueError(f"未対応の属性種類です。 :: attribute_type='{attribute_type}'") @@ -819,34 +905,34 @@ def _from_atomic_condition_dict(*, attribute_id: str, condition: dict[str, Any]) raise ValueError(f"未知の制約種別です。 :: _type='{condition_type}'") -def _ast_to_atomic_restriction(ast: RestrictionAst, *, fac: AttributeFactory, attribute: dict[str, Any]) -> Restriction: +def _ast_to_atomic_restriction(ast: RestrictionAst, *, fac: AttributeFactory, attribute: AttributeDefinition) -> Restriction: assert ast.attribute_name is not None attribute_type = attribute["type"] match ast.type: - case "checked": + case RestrictionAstType.CHECKED: restriction = fac.checkbox(attribute_name=ast.attribute_name).checked() - case "unchecked": + case RestrictionAstType.UNCHECKED: restriction = fac.checkbox(attribute_name=ast.attribute_name).unchecked() - case "is_empty": + case RestrictionAstType.IS_EMPTY: restriction = _attribute_with_empty_check(fac, ast.attribute_name).is_empty() - case "is_not_empty": + case RestrictionAstType.IS_NOT_EMPTY: restriction = _attribute_with_empty_check(fac, ast.attribute_name).is_not_empty() - case "can_input": + case RestrictionAstType.CAN_INPUT: assert ast.enable is not None attribute_obj = _create_attribute_object_with_name(fac, ast.attribute_name) restriction = attribute_obj.enabled() if ast.enable else attribute_obj.disabled() - case "equals_string" | "not_equals_string": + case RestrictionAstType.EQUALS_STRING | RestrictionAstType.NOT_EQUALS_STRING: restriction = _ast_string_equality_to_restriction(ast=ast, fac=fac, attribute=attribute, attribute_type=attribute_type) - case "matches_string" | "not_matches_string": + case RestrictionAstType.MATCHES_STRING | RestrictionAstType.NOT_MATCHES_STRING: restriction = _ast_string_match_to_restriction(ast=ast, fac=fac, attribute=attribute, attribute_type=attribute_type) - case "equals_integer" | "not_equals_integer": + case RestrictionAstType.EQUALS_INTEGER | RestrictionAstType.NOT_EQUALS_INTEGER: restriction = _ast_integer_to_restriction(ast=ast, fac=fac) - case "has_choice" | "not_has_choice": + case RestrictionAstType.HAS_CHOICE | RestrictionAstType.NOT_HAS_CHOICE: restriction = _ast_selection_to_restriction(ast=ast, fac=fac) - case "has_label": + case RestrictionAstType.HAS_LABEL: restriction = _ast_label_to_restriction(ast=ast, fac=fac) - case "imply": + case RestrictionAstType.IMPLY: raise AssertionError("`imply`は `_ast_to_restriction` で処理されるため、ここには到達しません。") case _ as never: assert_noreturn(never) @@ -857,7 +943,7 @@ def _ast_string_equality_to_restriction( *, ast: RestrictionAst, fac: AttributeFactory, - attribute: dict[str, Any], + attribute: AttributeDefinition, attribute_type: AdditionalDataDefinitionType, ) -> Restriction: assert isinstance(ast.value, str) @@ -871,9 +957,9 @@ def _ast_string_equality_to_restriction( _raise_invalid_ast(attribute=attribute, ast=ast) match ast.type: - case "equals_string": + case RestrictionAstType.EQUALS_STRING: return attribute_obj.equals(ast.value) - case "not_equals_string": + case RestrictionAstType.NOT_EQUALS_STRING: return attribute_obj.not_equals(ast.value) case _: raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") @@ -883,7 +969,7 @@ def _ast_string_match_to_restriction( *, ast: RestrictionAst, fac: AttributeFactory, - attribute: dict[str, Any], + attribute: AttributeDefinition, attribute_type: AdditionalDataDefinitionType, ) -> Restriction: assert isinstance(ast.value, str) @@ -892,9 +978,9 @@ def _ast_string_match_to_restriction( attribute_obj = fac.string_textbox(attribute_name=ast.attribute_name) match ast.type: - case "matches_string": + case RestrictionAstType.MATCHES_STRING: return attribute_obj.matches(ast.value) - case "not_matches_string": + case RestrictionAstType.NOT_MATCHES_STRING: return attribute_obj.not_matches(ast.value) case _: raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") @@ -904,9 +990,9 @@ def _ast_integer_to_restriction(*, ast: RestrictionAst, fac: AttributeFactory) - assert isinstance(ast.value, int) attribute_obj = fac.integer_textbox(attribute_name=ast.attribute_name) match ast.type: - case "equals_integer": + case RestrictionAstType.EQUALS_INTEGER: return attribute_obj.equals(ast.value) - case "not_equals_integer": + case RestrictionAstType.NOT_EQUALS_INTEGER: return attribute_obj.not_equals(ast.value) case _: raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") @@ -916,9 +1002,9 @@ def _ast_selection_to_restriction(*, ast: RestrictionAst, fac: AttributeFactory) assert ast.choice_name is not None attribute_obj = fac.selection(attribute_name=ast.attribute_name) match ast.type: - case "has_choice": + case RestrictionAstType.HAS_CHOICE: return attribute_obj.has_choice(choice_name=ast.choice_name) - case "not_has_choice": + case RestrictionAstType.NOT_HAS_CHOICE: return attribute_obj.not_has_choice(choice_name=ast.choice_name) case _: raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") @@ -929,7 +1015,7 @@ def _ast_label_to_restriction(*, ast: RestrictionAst, fac: AttributeFactory) -> return fac.annotation_link(attribute_name=ast.attribute_name).has_label(label_names=ast.label_names) -def _create_attribute_object(fac: AttributeFactory, attribute: dict[str, Any]) -> Attribute: +def _create_attribute_object(fac: AttributeFactory, attribute: AttributeDefinition) -> Attribute: """ 属性定義から対応する高水準属性オブジェクトを生成します。 @@ -992,7 +1078,7 @@ def _ast_to_restriction(ast: RestrictionAst, *, fac: AttributeFactory) -> Restri ValueError: AST種別が未知の場合、または属性型に対して利用できないASTが指定された場合 """ match ast.type: - case "imply": + case RestrictionAstType.IMPLY: assert ast.premise is not None assert ast.conclusion is not None premise_restriction = _ast_to_restriction(ast.premise, fac=fac) @@ -1008,78 +1094,82 @@ def _restriction_to_atomic_ast( restriction: Restriction, *, accessor: AnnotationSpecsAccessor, - attribute: dict[str, Any], + attribute: AttributeDefinition, attribute_name: str, ) -> RestrictionAst: attribute_type = attribute["type"] match restriction: case CanInput(enable=enable): - return RestrictionAst(type="can_input", attribute_name=attribute_name, enable=enable) + return RestrictionAst(type=RestrictionAstType.CAN_INPUT, attribute_name=attribute_name, enable=enable) case Equals(value=value): return _equals_restriction_to_ast(attribute=attribute, attribute_name=attribute_name, attribute_type=attribute_type, value=value) case NotEquals(value=value): return _not_equals_restriction_to_ast(attribute=attribute, attribute_name=attribute_name, attribute_type=attribute_type, value=value) case Matches(value=value) if attribute_type in {"text", "comment"}: - return RestrictionAst(type="matches_string", attribute_name=attribute_name, value=value) + return RestrictionAst(type=RestrictionAstType.MATCHES_STRING, attribute_name=attribute_name, value=value) case NotMatches(value=value) if attribute_type in {"text", "comment"}: - return RestrictionAst(type="not_matches_string", attribute_name=attribute_name, value=value) + return RestrictionAst(type=RestrictionAstType.NOT_MATCHES_STRING, attribute_name=attribute_name, value=value) case HasLabel(label_ids=label_ids) if attribute_type == "link": label_names = [get_english_message(accessor.get_label(label_id=label_id)["label_name"]) for label_id in label_ids] - return RestrictionAst(type="has_label", attribute_name=attribute_name, label_names=label_names) + return RestrictionAst(type=RestrictionAstType.HAS_LABEL, attribute_name=attribute_name, label_names=label_names) case _: _raise_invalid_restriction(attribute=attribute, condition=restriction.to_dict()["condition"]) def _equals_restriction_to_ast( *, - attribute: dict[str, Any], + attribute: AttributeDefinition, attribute_name: str, attribute_type: AdditionalDataDefinitionType, value: str, ) -> RestrictionAst: match attribute_type: case "flag" if value == "true": - return RestrictionAst(type="checked", attribute_name=attribute_name) + return RestrictionAst(type=RestrictionAstType.CHECKED, attribute_name=attribute_name) case "text" | "comment" | "integer" | "link" | "tracking" | "choice" | "select" if value == "": - return RestrictionAst(type="is_empty", attribute_name=attribute_name) + return RestrictionAst(type=RestrictionAstType.IS_EMPTY, attribute_name=attribute_name) case "text" | "comment" | "tracking": - return RestrictionAst(type="equals_string", attribute_name=attribute_name, value=value) + return RestrictionAst(type=RestrictionAstType.EQUALS_STRING, attribute_name=attribute_name, value=value) case "integer": return RestrictionAst( - type="equals_integer", + type=RestrictionAstType.EQUALS_INTEGER, attribute_name=attribute_name, value=_parse_integer_value(value, attribute=attribute, condition={"_type": "Equals", "value": value}), ) case "choice" | "select": - choice = get_choice(attribute["choices"], choice_id=value) - return RestrictionAst(type="has_choice", attribute_name=attribute_name, choice_name=get_english_message(choice["name"])) + choice = get_choice(_get_attribute_choices(attribute), choice_id=value) + return RestrictionAst(type=RestrictionAstType.HAS_CHOICE, attribute_name=attribute_name, choice_name=get_english_message(choice["name"])) case _: raise ValueError(f"RestrictionをASTへ変換できません。 :: restriction_type='Equals', attribute_type='{attribute_type}', value={value!r}") def _not_equals_restriction_to_ast( *, - attribute: dict[str, Any], + attribute: AttributeDefinition, attribute_name: str, attribute_type: AdditionalDataDefinitionType, value: str, ) -> RestrictionAst: match attribute_type: case "flag" if value == "true": - return RestrictionAst(type="unchecked", attribute_name=attribute_name) + return RestrictionAst(type=RestrictionAstType.UNCHECKED, attribute_name=attribute_name) case "text" | "comment" | "integer" | "link" | "tracking" | "choice" | "select" if value == "": - return RestrictionAst(type="is_not_empty", attribute_name=attribute_name) + return RestrictionAst(type=RestrictionAstType.IS_NOT_EMPTY, attribute_name=attribute_name) case "text" | "comment" | "tracking": - return RestrictionAst(type="not_equals_string", attribute_name=attribute_name, value=value) + return RestrictionAst(type=RestrictionAstType.NOT_EQUALS_STRING, attribute_name=attribute_name, value=value) case "integer": return RestrictionAst( - type="not_equals_integer", + type=RestrictionAstType.NOT_EQUALS_INTEGER, attribute_name=attribute_name, value=_parse_integer_value(value, attribute=attribute, condition={"_type": "NotEquals", "value": value}), ) case "choice" | "select": - choice = get_choice(attribute["choices"], choice_id=value) - return RestrictionAst(type="not_has_choice", attribute_name=attribute_name, choice_name=get_english_message(choice["name"])) + choice = get_choice(_get_attribute_choices(attribute), choice_id=value) + return RestrictionAst( + type=RestrictionAstType.NOT_HAS_CHOICE, + attribute_name=attribute_name, + choice_name=get_english_message(choice["name"]), + ) case _: raise ValueError( f"RestrictionをASTへ変換できません。 :: restriction_type='NotEquals', attribute_type='{attribute_type}', value={value!r}" @@ -1112,7 +1202,7 @@ def _attribute_with_empty_check(fac: AttributeFactory, attribute_name: str) -> E return attribute_obj -def _raise_invalid_ast(*, attribute: dict[str, Any], ast: RestrictionAst) -> NoReturn: +def _raise_invalid_ast(*, attribute: AttributeDefinition, ast: RestrictionAst) -> NoReturn: """ 属性型に対して不正なAST種別が指定されたことを表す例外を送出します。 @@ -1127,7 +1217,7 @@ def _raise_invalid_ast(*, attribute: dict[str, Any], ast: RestrictionAst) -> NoR raise ValueError(f"属性'{attribute_name}'(type='{attribute['type']}')ではAST種別'{ast.type}'を利用できません。") -def _parse_integer_value(value: str, *, attribute: dict[str, Any], condition: dict[str, Any]) -> int: +def _parse_integer_value(value: str, *, attribute: AttributeDefinition, condition: dict[str, Any]) -> int: """ 整数属性向けの文字列値を整数へ変換します。 @@ -1149,7 +1239,7 @@ def _parse_integer_value(value: str, *, attribute: dict[str, Any], condition: di raise AssertionError("unreachable") from exc -def _raise_invalid_restriction(*, attribute: dict[str, Any], condition: dict[str, Any], detail: str | None = None) -> NoReturn: +def _raise_invalid_restriction(*, attribute: AttributeDefinition, condition: dict[str, Any], detail: str | None = None) -> NoReturn: """ 属性型に対して不正な制約が指定されたことを表す例外を送出します。 @@ -1185,7 +1275,7 @@ def _restriction_to_ast(restriction: Restriction, *, accessor: AnnotationSpecsAc match restriction: case Imply(premise_restriction=premise_restriction, conclusion_restriction=conclusion_restriction): return RestrictionAst( - type="imply", + type=RestrictionAstType.IMPLY, premise=_restriction_to_ast(premise_restriction, accessor=accessor), conclusion=_restriction_to_ast(conclusion_restriction, accessor=accessor), ) @@ -1225,7 +1315,7 @@ def _restriction_to_atomic_python_expr( restriction: Restriction, *, accessor: AnnotationSpecsAccessor, - attribute: dict[str, Any], + attribute: AttributeDefinition, attribute_expr: str, ) -> str: attribute_type = attribute["type"] @@ -1254,7 +1344,7 @@ def _restriction_to_atomic_python_expr( def _equals_restriction_to_python_expr( *, - attribute: dict[str, Any], + attribute: AttributeDefinition, attribute_expr: str, attribute_type: AdditionalDataDefinitionType, value: str, @@ -1274,7 +1364,7 @@ def _equals_restriction_to_python_expr( case "tracking": expr = f"{attribute_expr}.equals({_repr_python_value(value)})" case "choice" | "select": - choice = get_choice(attribute["choices"], choice_id=value) + choice = get_choice(_get_attribute_choices(attribute), choice_id=value) choice_name = get_english_message(choice["name"]) expr = f"{attribute_expr}.has_choice(choice_name={_repr_python_value(choice_name)})" case _: @@ -1286,7 +1376,7 @@ def _equals_restriction_to_python_expr( def _not_equals_restriction_to_python_expr( *, - attribute: dict[str, Any], + attribute: AttributeDefinition, attribute_expr: str, attribute_type: AdditionalDataDefinitionType, value: str, @@ -1306,7 +1396,7 @@ def _not_equals_restriction_to_python_expr( case "tracking": expr = f"{attribute_expr}.not_equals({_repr_python_value(value)})" case "choice" | "select": - choice = get_choice(attribute["choices"], choice_id=value) + choice = get_choice(_get_attribute_choices(attribute), choice_id=value) choice_name = get_english_message(choice["name"]) expr = f"{attribute_expr}.not_has_choice(choice_name={_repr_python_value(choice_name)})" case _: @@ -1317,7 +1407,7 @@ def _not_equals_restriction_to_python_expr( return expr -def _attribute_to_python_expr(attribute: dict[str, Any], *, factory_name: str) -> str: +def _attribute_to_python_expr(attribute: AttributeDefinition, *, factory_name: str) -> str: """ 属性定義を `AttributeFactory` 呼び出しの Python 式へ変換します。 diff --git a/tests/util/test_attribute_restrictions.py b/tests/util/test_attribute_restrictions.py index 44d281e7..79ea1ef8 100644 --- a/tests/util/test_attribute_restrictions.py +++ b/tests/util/test_attribute_restrictions.py @@ -13,6 +13,7 @@ IntegerTextbox, Restriction, RestrictionAst, + RestrictionAstType, Selection, StringTextbox, TrackingId, @@ -278,9 +279,9 @@ def test__to_ast(self): actual = restriction.to_ast(accessor.annotation_specs) assert actual == RestrictionAst( - type="imply", - premise=RestrictionAst(type="checked", attribute_name="occluded"), - conclusion=RestrictionAst(type="is_not_empty", attribute_name="note"), + type=RestrictionAstType.IMPLY, + premise=RestrictionAst(type=RestrictionAstType.CHECKED, attribute_name="occluded"), + conclusion=RestrictionAst(type=RestrictionAstType.IS_NOT_EMPTY, attribute_name="note"), ) def test__to_human_readable(self): @@ -370,9 +371,9 @@ def test__from_dict__can_input_true(self): def test__from_ast(self): ast = RestrictionAst( - type="imply", - premise=RestrictionAst(type="checked", attribute_name="occluded"), - conclusion=RestrictionAst(type="has_choice", attribute_name="car_kind", choice_name="general_car"), + type=RestrictionAstType.IMPLY, + premise=RestrictionAst(type=RestrictionAstType.CHECKED, attribute_name="occluded"), + conclusion=RestrictionAst(type=RestrictionAstType.HAS_CHOICE, attribute_name="car_kind", choice_name="general_car"), ) actual = Restriction.from_ast(ast, accessor.annotation_specs) @@ -393,9 +394,9 @@ def test__from_ast(self): class Test__RestrictionAst: def test__model_dump(self): ast = RestrictionAst( - type="imply", - premise=RestrictionAst(type="checked", attribute_name="occluded"), - conclusion=RestrictionAst(type="is_not_empty", attribute_name="note"), + type=RestrictionAstType.IMPLY, + premise=RestrictionAst(type=RestrictionAstType.CHECKED, attribute_name="occluded"), + conclusion=RestrictionAst(type=RestrictionAstType.IS_NOT_EMPTY, attribute_name="note"), ) assert ast.model_dump(mode="python", exclude_none=True) == { @@ -414,13 +415,14 @@ def test__model_validate(self): ) assert actual == RestrictionAst( - type="imply", - premise=RestrictionAst(type="checked", attribute_name="occluded"), - conclusion=RestrictionAst(type="has_choice", attribute_name="car_kind", choice_name="general_car"), + type=RestrictionAstType.IMPLY, + premise=RestrictionAst(type=RestrictionAstType.CHECKED, attribute_name="occluded"), + conclusion=RestrictionAst(type=RestrictionAstType.HAS_CHOICE, attribute_name="car_kind", choice_name="general_car"), ) + assert actual.type is RestrictionAstType.IMPLY def test__to_restriction(self): - ast = RestrictionAst(type="matches_string", attribute_name="note", value="[abc]+") + ast = RestrictionAst(type=RestrictionAstType.MATCHES_STRING, attribute_name="note", value="[abc]+") actual = ast.to_restriction(accessor.annotation_specs) @@ -430,13 +432,13 @@ def test__to_restriction(self): } def test__to_restriction__trackingにはmatches_stringを指定できない(self): - ast = RestrictionAst(type="matches_string", attribute_name="tracking", value="foo") + ast = RestrictionAst(type=RestrictionAstType.MATCHES_STRING, attribute_name="tracking", value="foo") with pytest.raises(ValueError, match="属性'tracking'\\(type='tracking'\\)ではAST種別'matches_string'を利用できません。"): ast.to_restriction(accessor.annotation_specs) def test__to_human_readable(self): - ast = RestrictionAst(type="has_label", attribute_name="link_car", label_names=["car", "number_plate"]) + ast = RestrictionAst(type=RestrictionAstType.HAS_LABEL, attribute_name="link_car", label_names=["car", "number_plate"]) actual = ast.to_human_readable() @@ -444,7 +446,7 @@ def test__to_human_readable(self): def test__invalid_fields(self): with pytest.raises(ValidationError): - RestrictionAst(type="equals_string", attribute_name="note") + RestrictionAst(type=RestrictionAstType.EQUALS_STRING, attribute_name="note") def test__model_json_schema(self): actual = RestrictionAst.model_json_schema() @@ -460,6 +462,7 @@ def test__catalog(self): actual = get_attribute_restriction_catalog(accessor.annotation_specs) assert all(isinstance(item, AttributeRestrictionCatalogItem) for item in actual) + assert isinstance(actual[0].allowed_ast_types[0], RestrictionAstType) assert { "attribute_name": "tracking", "attribute_type": "tracking", From 1ca35e7eff3a08bd5e3dfe08c0083bcb6e93f96d Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 02:05:54 +0900 Subject: [PATCH 23/47] Add docstrings to RestrictionAstType members --- annofabapi/util/attribute_restrictions.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index 160b78aa..ba40b66a 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -43,20 +43,35 @@ class RestrictionAstType(str, Enum): """属性制約ASTの種別です。""" CHECKED = "checked" + """チェックボックス属性がチェックされていることを表すAST種別です。""" UNCHECKED = "unchecked" + """チェックボックス属性がチェックされていないことを表すAST種別です。""" IS_EMPTY = "is_empty" + """属性値が空であることを表すAST種別です。""" IS_NOT_EMPTY = "is_not_empty" + """属性値が空でないことを表すAST種別です。""" EQUALS_STRING = "equals_string" + """文字列属性またはtracking属性が指定文字列と一致することを表すAST種別です。""" NOT_EQUALS_STRING = "not_equals_string" + """文字列属性またはtracking属性が指定文字列と一致しないことを表すAST種別です。""" MATCHES_STRING = "matches_string" + """文字列属性が指定した正規表現に一致することを表すAST種別です。""" NOT_MATCHES_STRING = "not_matches_string" + """文字列属性が指定した正規表現に一致しないことを表すAST種別です。""" EQUALS_INTEGER = "equals_integer" + """整数属性が指定した整数値と一致することを表すAST種別です。""" NOT_EQUALS_INTEGER = "not_equals_integer" + """整数属性が指定した整数値と一致しないことを表すAST種別です。""" HAS_CHOICE = "has_choice" + """選択属性で指定した選択肢が選ばれていることを表すAST種別です。""" NOT_HAS_CHOICE = "not_has_choice" + """選択属性で指定した選択肢が選ばれていないことを表すAST種別です。""" HAS_LABEL = "has_label" + """リンク属性が指定したラベル群のいずれかを指すことを表すAST種別です。""" CAN_INPUT = "can_input" + """属性が編集可能かどうかを表すAST種別です。""" IMPLY = "imply" + """前提を満たす場合に結論を要求する含意制約を表すAST種別です。""" def __str__(self) -> str: return self.value From 97f05436fdb3c6f77c8788aa5b8a9a022eb9d1ee Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 02:14:15 +0900 Subject: [PATCH 24/47] =?UTF-8?q?TypedDict=E3=82=92=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E3=81=97=E3=81=A6=E5=9B=BD=E9=9A=9B=E5=8C=96=E3=83=A1=E3=83=83?= =?UTF-8?q?=E3=82=BB=E3=83=BC=E3=82=B8=E3=81=AE=E6=A7=8B=E9=80=A0=E3=82=92?= =?UTF-8?q?=E5=AE=9A=E7=BE=A9=E3=81=97=E3=80=81=E5=88=B6=E7=B4=84AST?= =?UTF-8?q?=E3=81=AE=E6=A4=9C=E8=A8=BC=E3=83=AD=E3=82=B8=E3=83=83=E3=82=AF?= =?UTF-8?q?=E3=82=92=E6=94=B9=E5=96=84=E3=81=97=E3=81=BE=E3=81=97=E3=81=9F?= =?UTF-8?q?=E3=80=82=E3=81=BE=E3=81=9F=E3=80=81=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=82=B3=E3=83=BC=E3=83=89=E3=82=92=E6=9B=B4=E6=96=B0=E3=81=97?= =?UTF-8?q?=E3=81=A6=E6=96=B0=E3=81=97=E3=81=84=E5=9E=8B=E3=81=AB=E5=AF=BE?= =?UTF-8?q?=E5=BF=9C=E3=81=97=E3=81=BE=E3=81=97=E3=81=9F=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- annofabapi/util/annotation_specs.py | 68 ++++++++++++++++++----- annofabapi/util/attribute_restrictions.py | 58 ++++++++----------- tests/util/test_annotation_specs.py | 4 +- 3 files changed, 78 insertions(+), 52 deletions(-) diff --git a/annofabapi/util/annotation_specs.py b/annofabapi/util/annotation_specs.py index 252434cd..c13eeb00 100644 --- a/annofabapi/util/annotation_specs.py +++ b/annofabapi/util/annotation_specs.py @@ -1,11 +1,49 @@ -from typing import Any, Literal +from typing import Any, Literal, TypedDict, cast import more_itertools from annofabapi.models import Lang +from annofabapi.pydantic_models.additional_data_definition_type import AdditionalDataDefinitionType -def get_english_message(internationalization_message: dict[str, Any]) -> str: +class InternationalizationMessageItem(TypedDict): + """多言語メッセージの1要素です。""" + + lang: str + message: str + + +class InternationalizationMessage(TypedDict): + """多言語メッセージです。""" + + messages: list[InternationalizationMessageItem] + + +class AttributeChoice(TypedDict): + """属性の選択肢です。""" + + choice_id: str + name: InternationalizationMessage + + +class AttributeDefinition(TypedDict): + """アノテーション仕様上の属性定義です。""" + + additional_data_definition_id: str + name: InternationalizationMessage + type: AdditionalDataDefinitionType + choices: list[AttributeChoice] | None + + +class LabelDefinition(TypedDict): + """アノテーション仕様上のラベル定義です。""" + + label_id: str + label_name: InternationalizationMessage + additional_data_definitions: list[str] + + +def get_english_message(internationalization_message: InternationalizationMessage | dict[str, Any]) -> str: """ `InternationalizationMessage`クラスの値から、英語メッセージを取得します。 英語メッセージが見つからない場合は ``ValueError`` をスローします。 @@ -22,7 +60,7 @@ def get_english_message(internationalization_message: dict[str, Any]) -> str: Raises: ValueError: 英語メッセージが見つからない場合 """ - messages: list[dict[str, str]] = internationalization_message["messages"] + messages = cast(list[InternationalizationMessageItem], internationalization_message["messages"]) result = more_itertools.first_true(messages, pred=lambda e: e["lang"] == Lang.EN_US.value) if result is not None: return result["message"] @@ -36,7 +74,7 @@ def get_english_message(internationalization_message: dict[str, Any]) -> str: """ -def get_message_with_lang(internationalization_message: dict[str, Any], lang: Lang | STR_LANG) -> str | None: +def get_message_with_lang(internationalization_message: InternationalizationMessage | dict[str, Any], lang: Lang | STR_LANG) -> str | None: """ `InternationalizationMessage`クラスの値から、指定した ``lang`` に対応するメッセージを取得します。 @@ -48,7 +86,7 @@ def get_message_with_lang(internationalization_message: dict[str, Any], lang: La 指定した言語に対応するメッセージ。見つからない場合はNoneを返します。 """ - messages: list[dict[str, str]] = internationalization_message["messages"] + messages = cast(list[InternationalizationMessageItem], internationalization_message["messages"]) if isinstance(lang, Lang): str_lang = lang.value else: @@ -60,7 +98,7 @@ def get_message_with_lang(internationalization_message: dict[str, Any], lang: La return None -def get_choice(choices: list[dict[str, Any]], *, choice_id: str | None = None, choice_name: str | None = None) -> dict[str, Any]: +def get_choice(choices: list[AttributeChoice], *, choice_id: str | None = None, choice_name: str | None = None) -> AttributeChoice: """ 選択肢情報を取得します。 @@ -90,12 +128,12 @@ def get_choice(choices: list[dict[str, Any]], *, choice_id: str | None = None, c def get_attribute( - additionals: list[dict[str, Any]], + additionals: list[AttributeDefinition], *, attribute_id: str | None = None, attribute_name: str | None = None, - label: dict[str, Any] | None = None, -) -> dict[str, Any]: + label: LabelDefinition | None = None, +) -> AttributeDefinition: """ 属性情報を取得します。 @@ -133,7 +171,7 @@ def get_attribute( return result[0] -def get_label(labels: list[dict[str, Any]], *, label_id: str | None = None, label_name: str | None = None) -> dict[str, Any]: +def get_label(labels: list[LabelDefinition], *, label_id: str | None = None, label_name: str | None = None) -> LabelDefinition: """ ラベル情報を取得します。 @@ -172,12 +210,12 @@ class AnnotationSpecsAccessor: def __init__(self, annotation_specs: dict[str, Any]) -> None: self.annotation_specs = annotation_specs - self.labels = annotation_specs["labels"] - self.additionals = annotation_specs["additionals"] + self.labels: list[LabelDefinition] = annotation_specs["labels"] + self.additionals: list[AttributeDefinition] = annotation_specs["additionals"] def get_attribute( - self, *, attribute_id: str | None = None, attribute_name: str | None = None, label: dict[str, Any] | None = None - ) -> dict[str, Any]: + self, *, attribute_id: str | None = None, attribute_name: str | None = None, label: LabelDefinition | None = None + ) -> AttributeDefinition: """ 属性情報を取得します。 @@ -192,7 +230,7 @@ def get_attribute( """ return get_attribute(self.additionals, attribute_id=attribute_id, attribute_name=attribute_name, label=label) - def get_label(self, *, label_id: str | None = None, label_name: str | None = None) -> dict[str, Any]: + def get_label(self, *, label_id: str | None = None, label_name: str | None = None) -> LabelDefinition: """ ラベル情報を取得します。 diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index ba40b66a..93cb9341 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -453,12 +453,30 @@ class RestrictionAst(BaseModel): @model_validator(mode="after") def validate_restriction_ast(self) -> "RestrictionAst": - _validate_restriction_ast(self) - return self + """ + `RestrictionAst` の構造がAST種別に整合しているか検証します。 - @field_serializer("type") - def serialize_type(self, ast_type: RestrictionAstType) -> str: - return ast_type.value + Raises: + ValueError: AST種別に対して必須フィールドが不足している場合、または型が不正な場合 + """ + required_fields = _get_required_ast_fields(self.type) + actual_fields = { + field_name + for field_name, value in ( + ("attribute_name", self.attribute_name), + ("value", self.value), + ("choice_name", self.choice_name), + ("enable", self.enable), + ("label_names", self.label_names), + ("premise", self.premise), + ("conclusion", self.conclusion), + ) + if value is not None + } + if actual_fields != required_fields: + raise ValueError(f"AST種別'{self.type}'のフィールドが不正です。 :: required={sorted(required_fields)}, actual={sorted(actual_fields)}") + _validate_restriction_ast_field_types(self) + return self def to_restriction(self, annotation_specs: dict[str, Any]) -> Restriction: """ @@ -689,36 +707,6 @@ def _get_required_ast_fields(ast_type: RestrictionAstType) -> set[str]: assert_noreturn(never) -def _validate_restriction_ast(ast: RestrictionAst) -> None: - """ - `RestrictionAst` の構造がAST種別に整合しているか検証します。 - - Args: - ast: 検証対象のASTです。 - - Raises: - ValueError: AST種別に対して必須フィールドが不足している場合、または型が不正な場合 - """ - required_fields = _get_required_ast_fields(ast.type) - actual_fields = { - field_name - for field_name, value in ( - ("attribute_name", ast.attribute_name), - ("value", ast.value), - ("choice_name", ast.choice_name), - ("enable", ast.enable), - ("label_names", ast.label_names), - ("premise", ast.premise), - ("conclusion", ast.conclusion), - ) - if value is not None - } - if actual_fields != required_fields: - raise ValueError(f"AST種別'{ast.type}'のフィールドが不正です。 :: required={sorted(required_fields)}, actual={sorted(actual_fields)}") - - _validate_restriction_ast_field_types(ast) - - def _validate_restriction_ast_field_types(ast: RestrictionAst) -> None: """ `RestrictionAst` の値フィールドがAST種別に整合しているか検証します。 diff --git a/tests/util/test_annotation_specs.py b/tests/util/test_annotation_specs.py index f9f5d813..e508c0eb 100644 --- a/tests/util/test_annotation_specs.py +++ b/tests/util/test_annotation_specs.py @@ -1,6 +1,6 @@ import pytest -from annofabapi.util.annotation_specs import AnnotationSpecsAccessor, Lang, get_choice, get_english_message, get_message_with_lang +from annofabapi.util.annotation_specs import AnnotationSpecsAccessor, AttributeChoice, Lang, get_choice, get_english_message, get_message_with_lang class Test__get_english_message: @@ -87,7 +87,7 @@ def test_get_attribute_by_id_and_label__not_found(self): class Test__get_choice: def setup_method(self): - self.choices = [ + self.choices: list[AttributeChoice] = [ {"choice_id": "1", "name": {"messages": [{"lang": "en-US", "message": "Option1"}]}}, {"choice_id": "2", "name": {"messages": [{"lang": "en-US", "message": "Option2"}]}}, ] From b5100efb15821702d394b9cff09eb28e8ff614b8 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 02:17:22 +0900 Subject: [PATCH 25/47] =?UTF-8?q?AGENTS.md=E3=81=AB=E3=83=AA=E3=83=B3?= =?UTF-8?q?=E3=82=BF=E3=83=BC=E3=82=A8=E3=83=A9=E3=83=BC=E3=81=AE=E7=84=A1?= =?UTF-8?q?=E8=A6=96=E3=81=AB=E9=96=A2=E3=81=99=E3=82=8B=E6=96=B9=E9=87=9D?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=97=E3=80=81RestrictionAst?= =?UTF-8?q?=E3=82=AF=E3=83=A9=E3=82=B9=E3=81=AE=E3=83=95=E3=82=A3=E3=83=BC?= =?UTF-8?q?=E3=83=AB=E3=83=89=E6=A4=9C=E8=A8=BC=E5=BE=8C=E3=81=AB=E5=9E=8B?= =?UTF-8?q?=E6=A4=9C=E8=A8=BC=E3=82=92=E5=AE=9F=E8=A1=8C=E3=81=99=E3=82=8B?= =?UTF-8?q?=E3=82=88=E3=81=86=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + annofabapi/util/attribute_restrictions.py | 1 + 2 files changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 86eab9f9..a7553ec1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,7 @@ ## コードの修正方針 * 原則、破壊的変更を行って修正してください。コードをシンプルにするためです。 * 何かを判断する際、コードの修正量は無視してください。AIが修正するので、そこは問題になりません。 +* リンターにより行数が長すぎるなどのエラーが発生した場合、必要ならばそのエラーを無視してください。読みやすさやシンプルさを優先してください。 ## Coding Agent による作業の進め方 1. コードを修正する。関連するテストコードやドキュメントも修正する。 diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index 93cb9341..c9bd0952 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -475,6 +475,7 @@ def validate_restriction_ast(self) -> "RestrictionAst": } if actual_fields != required_fields: raise ValueError(f"AST種別'{self.type}'のフィールドが不正です。 :: required={sorted(required_fields)}, actual={sorted(actual_fields)}") + _validate_restriction_ast_field_types(self) return self From c3636a5c88f842177010afab1604e5d34f4151d3 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 02:19:53 +0900 Subject: [PATCH 26/47] Inline RestrictionAst field type validation --- annofabapi/util/attribute_restrictions.py | 78 ++++++++++------------- tests/util/test_attribute_restrictions.py | 4 ++ 2 files changed, 37 insertions(+), 45 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index c9bd0952..91e29721 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -452,7 +452,7 @@ class RestrictionAst(BaseModel): conclusion: "RestrictionAst | None" = Field(default=None, description="`imply` ノードの結論です。") @model_validator(mode="after") - def validate_restriction_ast(self) -> "RestrictionAst": + def validate_restriction_ast(self) -> "RestrictionAst": # noqa: PLR0912 """ `RestrictionAst` の構造がAST種別に整合しているか検証します。 @@ -476,7 +476,38 @@ def validate_restriction_ast(self) -> "RestrictionAst": if actual_fields != required_fields: raise ValueError(f"AST種別'{self.type}'のフィールドが不正です。 :: required={sorted(required_fields)}, actual={sorted(actual_fields)}") - _validate_restriction_ast_field_types(self) + match self.type: + case ( + RestrictionAstType.EQUALS_STRING + | RestrictionAstType.NOT_EQUALS_STRING + | RestrictionAstType.MATCHES_STRING + | RestrictionAstType.NOT_MATCHES_STRING + ): + if not isinstance(self.value, str): + raise ValueError(f"AST種別'{self.type}'の'value'は文字列である必要があります。") + case RestrictionAstType.EQUALS_INTEGER | RestrictionAstType.NOT_EQUALS_INTEGER: + if not isinstance(self.value, int): + raise ValueError(f"AST種別'{self.type}'の'value'は整数である必要があります。") + case RestrictionAstType.HAS_CHOICE | RestrictionAstType.NOT_HAS_CHOICE: + if not isinstance(self.choice_name, str): + raise ValueError(f"AST種別'{self.type}'の'choice_name'は文字列である必要があります。") + case RestrictionAstType.HAS_LABEL: + if not isinstance(self.label_names, list) or any(not isinstance(label_name, str) for label_name in self.label_names): + raise ValueError("AST種別'has_label'の'label_names'は文字列のリストである必要があります。") + case RestrictionAstType.CAN_INPUT: + if not isinstance(self.enable, bool): + raise ValueError("AST種別'can_input'の'enable'は真偽値である必要があります。") + case ( + RestrictionAstType.CHECKED + | RestrictionAstType.UNCHECKED + | RestrictionAstType.IS_EMPTY + | RestrictionAstType.IS_NOT_EMPTY + | RestrictionAstType.IMPLY + ): + pass + case _ as never: + assert_noreturn(never) + return self def to_restriction(self, annotation_specs: dict[str, Any]) -> Restriction: @@ -708,49 +739,6 @@ def _get_required_ast_fields(ast_type: RestrictionAstType) -> set[str]: assert_noreturn(never) -def _validate_restriction_ast_field_types(ast: RestrictionAst) -> None: - """ - `RestrictionAst` の値フィールドがAST種別に整合しているか検証します。 - - Args: - ast: 検証対象のASTです。 - - Raises: - ValueError: AST種別に対して値の型が不正な場合 - """ - match ast.type: - case ( - RestrictionAstType.EQUALS_STRING - | RestrictionAstType.NOT_EQUALS_STRING - | RestrictionAstType.MATCHES_STRING - | RestrictionAstType.NOT_MATCHES_STRING - ): - if not isinstance(ast.value, str): - raise ValueError(f"AST種別'{ast.type}'の'value'は文字列である必要があります。") - case RestrictionAstType.EQUALS_INTEGER | RestrictionAstType.NOT_EQUALS_INTEGER: - if not isinstance(ast.value, int): - raise ValueError(f"AST種別'{ast.type}'の'value'は整数である必要があります。") - case RestrictionAstType.HAS_CHOICE | RestrictionAstType.NOT_HAS_CHOICE: - if not isinstance(ast.choice_name, str): - raise ValueError(f"AST種別'{ast.type}'の'choice_name'は文字列である必要があります。") - case RestrictionAstType.HAS_LABEL: - if not isinstance(ast.label_names, list) or any(not isinstance(label_name, str) for label_name in ast.label_names): - raise ValueError("AST種別'has_label'の'label_names'は文字列のリストである必要があります。") - case RestrictionAstType.CAN_INPUT: - if not isinstance(ast.enable, bool): - raise ValueError("AST種別'can_input'の'enable'は真偽値である必要があります。") - case ( - RestrictionAstType.CHECKED - | RestrictionAstType.UNCHECKED - | RestrictionAstType.IS_EMPTY - | RestrictionAstType.IS_NOT_EMPTY - | RestrictionAstType.IMPLY - ): - pass - case _ as never: - assert_noreturn(never) - - def _restriction_ast_to_human_readable_text(ast: RestrictionAst, *, attribute_name: str) -> str: # noqa: PLR0912 """ `imply` 以外のASTを人間向けの読みやすい文字列へ変換します。 diff --git a/tests/util/test_attribute_restrictions.py b/tests/util/test_attribute_restrictions.py index 79ea1ef8..3df6c346 100644 --- a/tests/util/test_attribute_restrictions.py +++ b/tests/util/test_attribute_restrictions.py @@ -448,6 +448,10 @@ def test__invalid_fields(self): with pytest.raises(ValidationError): RestrictionAst(type=RestrictionAstType.EQUALS_STRING, attribute_name="note") + def test__invalid_field_type(self): + with pytest.raises(ValidationError): + RestrictionAst(type=RestrictionAstType.EQUALS_STRING, attribute_name="note", value=1) + def test__model_json_schema(self): actual = RestrictionAst.model_json_schema() properties = actual["$defs"]["RestrictionAst"]["properties"] From 9f3a95afb3ee8050f46b2284244f411f1651e022 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 02:24:37 +0900 Subject: [PATCH 27/47] Remove restriction to_python_expr API --- annofabapi/util/attribute_restrictions.py | 188 ---------------------- tests/util/test_attribute_restrictions.py | 36 +---- 2 files changed, 5 insertions(+), 219 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index 91e29721..bb06dd35 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -108,23 +108,6 @@ def from_dict(cls, obj: dict[str, Any]) -> "Restriction": """ return _from_restriction_dict(obj) - def to_python_expr(self, annotation_specs: dict[str, Any], *, factory_name: str = "fac") -> str: - """ - Restrictionオブジェクトを、高水準APIに近いPython式へ変換します。 - - Args: - annotation_specs: アノテーション仕様(v3)の情報です。 - factory_name: `AttributeFactory` の変数名です。 - - Returns: - 高水準APIに近い Python 式です。 - - Raises: - ValueError: 高水準APIの式へ変換できない制約が含まれている場合 - """ - accessor = AnnotationSpecsAccessor(annotation_specs) - return _restriction_to_python_expr(self, accessor=accessor, factory_name=factory_name) - def to_ast(self, annotation_specs: dict[str, Any]) -> "RestrictionAst": """ Restrictionオブジェクトを、LLMやCLIで扱いやすい意味ベースのASTへ変換します。 @@ -1275,174 +1258,3 @@ def _restriction_to_ast(restriction: Restriction, *, accessor: AnnotationSpecsAc attribute = accessor.get_attribute(attribute_id=restriction.attribute_id) attribute_name = get_english_message(attribute["name"]) return _restriction_to_atomic_ast(restriction, accessor=accessor, attribute=attribute, attribute_name=attribute_name) - - -def _restriction_to_python_expr(restriction: Restriction, *, accessor: AnnotationSpecsAccessor, factory_name: str) -> str: - """ - `Restriction` を fluent API 形式の Python 式へ変換します。 - - Args: - restriction: 変換元の `Restriction` です。 - accessor: 属性名や選択肢名の解決に使う `AnnotationSpecsAccessor` です。 - factory_name: `AttributeFactory` の変数名です。 - - Returns: - 変換後の Python 式です。 - - Raises: - ValueError: Python 式へ変換できない制約が含まれている場合 - """ - match restriction: - case Imply(premise_restriction=premise_restriction, conclusion_restriction=conclusion_restriction): - premise_expr = _restriction_to_python_expr(premise_restriction, accessor=accessor, factory_name=factory_name) - conclusion_expr = _restriction_to_python_expr(conclusion_restriction, accessor=accessor, factory_name=factory_name) - return f"{premise_expr}.imply({conclusion_expr})" - - attribute = accessor.get_attribute(attribute_id=restriction.attribute_id) - attribute_expr = _attribute_to_python_expr(attribute, factory_name=factory_name) - return _restriction_to_atomic_python_expr(restriction, accessor=accessor, attribute=attribute, attribute_expr=attribute_expr) - - -def _restriction_to_atomic_python_expr( - restriction: Restriction, - *, - accessor: AnnotationSpecsAccessor, - attribute: AttributeDefinition, - attribute_expr: str, -) -> str: - attribute_type = attribute["type"] - match restriction: - case CanInput(enable=enable): - return f"{attribute_expr}.enabled()" if enable else f"{attribute_expr}.disabled()" - case Equals(value=value): - return _equals_restriction_to_python_expr(attribute=attribute, attribute_expr=attribute_expr, attribute_type=attribute_type, value=value) - case NotEquals(value=value): - return _not_equals_restriction_to_python_expr( - attribute=attribute, - attribute_expr=attribute_expr, - attribute_type=attribute_type, - value=value, - ) - case Matches(value=value) if attribute_type in {"text", "comment"}: - return f"{attribute_expr}.matches({_repr_python_value(value)})" - case NotMatches(value=value) if attribute_type in {"text", "comment"}: - return f"{attribute_expr}.not_matches({_repr_python_value(value)})" - case HasLabel(label_ids=label_ids) if attribute_type == "link": - label_names = [get_english_message(accessor.get_label(label_id=label_id)["label_name"]) for label_id in label_ids] - return f"{attribute_expr}.has_label(label_names={_repr_python_value(label_names)})" - case _: - raise ValueError(f"Restrictionを高水準APIのPython式へ変換できません。 :: restriction={restriction.to_dict()}") - - -def _equals_restriction_to_python_expr( - *, - attribute: AttributeDefinition, - attribute_expr: str, - attribute_type: AdditionalDataDefinitionType, - value: str, -) -> str: - if value == "": - match attribute_type: - case "text" | "comment" | "integer" | "link" | "tracking" | "choice" | "select": - return f"{attribute_expr}.is_empty()" - - match attribute_type: - case "flag" if value == "true": - expr = f"{attribute_expr}.checked()" - case "text" | "comment": - expr = f"{attribute_expr}.equals({_repr_python_value(value)})" - case "integer": - expr = f"{attribute_expr}.equals({int(value)})" - case "tracking": - expr = f"{attribute_expr}.equals({_repr_python_value(value)})" - case "choice" | "select": - choice = get_choice(_get_attribute_choices(attribute), choice_id=value) - choice_name = get_english_message(choice["name"]) - expr = f"{attribute_expr}.has_choice(choice_name={_repr_python_value(choice_name)})" - case _: - raise ValueError( - f"Restrictionを高水準APIのPython式へ変換できません。 :: restriction_type='Equals', attribute_type='{attribute_type}', value={value!r}" - ) - return expr - - -def _not_equals_restriction_to_python_expr( - *, - attribute: AttributeDefinition, - attribute_expr: str, - attribute_type: AdditionalDataDefinitionType, - value: str, -) -> str: - if value == "": - match attribute_type: - case "text" | "comment" | "integer" | "link" | "tracking" | "choice" | "select": - return f"{attribute_expr}.is_not_empty()" - - match attribute_type: - case "flag" if value == "true": - expr = f"{attribute_expr}.unchecked()" - case "text" | "comment": - expr = f"{attribute_expr}.not_equals({_repr_python_value(value)})" - case "integer": - expr = f"{attribute_expr}.not_equals({int(value)})" - case "tracking": - expr = f"{attribute_expr}.not_equals({_repr_python_value(value)})" - case "choice" | "select": - choice = get_choice(_get_attribute_choices(attribute), choice_id=value) - choice_name = get_english_message(choice["name"]) - expr = f"{attribute_expr}.not_has_choice(choice_name={_repr_python_value(choice_name)})" - case _: - raise ValueError( - "Restrictionを高水準APIのPython式へ変換できません。 " - f":: restriction_type='NotEquals', attribute_type='{attribute_type}', value={value!r}" - ) - return expr - - -def _attribute_to_python_expr(attribute: AttributeDefinition, *, factory_name: str) -> str: - """ - 属性定義を `AttributeFactory` 呼び出しの Python 式へ変換します。 - - Args: - attribute: アノテーション仕様上の属性定義です。 - factory_name: `AttributeFactory` の変数名です。 - - Returns: - 属性生成部分の Python 式です。 - - Raises: - ValueError: 未対応の属性種類が指定された場合 - """ - attribute_name = get_english_message(attribute["name"]) - attribute_type: AdditionalDataDefinitionType = attribute["type"] - - match attribute_type: - case "flag": - factory_method = "checkbox" - case "text" | "comment": - factory_method = "string_textbox" - case "integer": - factory_method = "integer_textbox" - case "link": - factory_method = "annotation_link" - case "tracking": - factory_method = "tracking_id" - case "choice" | "select": - factory_method = "selection" - case _: - raise ValueError(f"未対応の属性種類です。 :: attribute_type='{attribute_type}'") - - return f"{factory_name}.{factory_method}(attribute_name={_repr_python_value(attribute_name)})" - - -def _repr_python_value(value: object) -> str: - """ - Python 式へ埋め込む値を `repr()` で文字列化します。 - - Args: - value: 文字列化する値です。 - - Returns: - `repr()` による文字列表現です。 - """ - return repr(value) diff --git a/tests/util/test_attribute_restrictions.py b/tests/util/test_attribute_restrictions.py index 3df6c346..d985c239 100644 --- a/tests/util/test_attribute_restrictions.py +++ b/tests/util/test_attribute_restrictions.py @@ -231,36 +231,6 @@ def test__from_dict(self): assert actual.to_dict() == restriction_dict - def test__to_python_expr(self): - restriction_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": ""}, - }, - } - restriction = Restriction.from_dict(restriction_dict) - - actual = restriction.to_python_expr(accessor.annotation_specs) - - assert actual == "fac.checkbox(attribute_name='occluded').checked().imply(fac.string_textbox(attribute_name='note').is_not_empty())" - - def test__to_python_expr__selection(self): - restriction = Restriction.from_dict( - { - "additional_data_definition_id": "cbb0155f-1631-48e1-8fc3-43c5f254b6f2", - "condition": {"_type": "NotEquals", "value": "7512ee39-8073-4e24-9b8c-93d99b76b7d2"}, - } - ) - - actual = restriction.to_python_expr(accessor.annotation_specs) - - assert actual == "fac.selection(attribute_name='car_kind').not_has_choice(choice_name='general_car')" - def test__to_ast(self): restriction = Restriction.from_dict( { @@ -367,7 +337,11 @@ def test__from_dict__can_input_true(self): restriction = Restriction.from_dict(restriction_dict) assert restriction.to_dict() == restriction_dict - assert restriction.to_python_expr(accessor.annotation_specs) == "fac.checkbox(attribute_name='occluded').enabled()" + assert restriction.to_ast(accessor.annotation_specs) == RestrictionAst( + type=RestrictionAstType.CAN_INPUT, + attribute_name="occluded", + enable=True, + ) def test__from_ast(self): ast = RestrictionAst( From b905f236579fa92156835fc0cbdf716d5fcd02e9 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 02:27:26 +0900 Subject: [PATCH 28/47] =?UTF-8?q?RestrictionAst=E3=82=AF=E3=83=A9=E3=82=B9?= =?UTF-8?q?=E3=81=AE=E3=83=89=E3=82=AD=E3=83=A5=E3=83=A1=E3=83=B3=E3=83=86?= =?UTF-8?q?=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3=E3=81=8B=E3=82=89=E4=B8=8D?= =?UTF-8?q?=E8=A6=81=E3=81=AA=E5=BC=95=E6=95=B0=E8=AA=AC=E6=98=8E=E3=82=92?= =?UTF-8?q?=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- annofabapi/util/attribute_restrictions.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index bb06dd35..08663379 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -412,15 +412,6 @@ class RestrictionAst(BaseModel): `type` に応じて必要なフィールドが変わります。例えば `checked` では `attribute_name` を使い、`imply` では `premise` と `conclusion` を使います。 - Args: - type: ASTノードの種類です。 - attribute_name: 対象属性の名前です。 - value: 文字列や整数の比較値です。 - choice_name: 選択肢名です。 - enable: `can_input` ノードで使う真偽値です。 - label_names: `has_label` ノードで使うラベル名の一覧です。 - premise: `imply` ノードの前提です。 - conclusion: `imply` ノードの結論です。 """ model_config = ConfigDict(extra="forbid", frozen=True) From 730b37cbecd6f32d4e3934229119cc4472d81ca0 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 02:31:54 +0900 Subject: [PATCH 29/47] Inline atomic restriction parsing --- annofabapi/util/attribute_restrictions.py | 36 ++++++----------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index 08663379..5e2d3551 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -830,45 +830,27 @@ def _from_condition_dict(*, attribute_id: str, condition: dict[str, Any]) -> Res 復元した `Restriction` オブジェクトです。 """ condition_type = condition["_type"] + restriction: Restriction match condition_type: case "Imply": premise_restriction = _from_restriction_dict(condition["premise"]) conclusion_restriction = _from_condition_dict(attribute_id=attribute_id, condition=condition["condition"]) - return Imply(premise_restriction=premise_restriction, conclusion_restriction=conclusion_restriction) - case _: - return _from_atomic_condition_dict(attribute_id=attribute_id, condition=condition) - - -def _from_atomic_condition_dict(*, attribute_id: str, condition: dict[str, Any]) -> Restriction: - """ - 原子的な条件辞書から `Restriction` を復元します。 - - Args: - attribute_id: 対象属性のIDです。 - condition: 条件部分のみを表す辞書です。 - - Returns: - 復元した `Restriction` オブジェクトです。 - - Raises: - ValueError: 未知の制約種別が指定された場合 - """ - condition_type = condition["_type"] - match condition_type: + restriction = Imply(premise_restriction=premise_restriction, conclusion_restriction=conclusion_restriction) case "CanInput": - return CanInput(attribute_id, enable=condition["enable"]) + restriction = CanInput(attribute_id, enable=condition["enable"]) case "Equals": - return Equals(attribute_id, value=condition["value"]) + restriction = Equals(attribute_id, value=condition["value"]) case "NotEquals": - return NotEquals(attribute_id, value=condition["value"]) + restriction = NotEquals(attribute_id, value=condition["value"]) case "Matches": - return Matches(attribute_id, value=condition["value"]) + restriction = Matches(attribute_id, value=condition["value"]) case "NotMatches": - return NotMatches(attribute_id, value=condition["value"]) + restriction = NotMatches(attribute_id, value=condition["value"]) case "HasLabel": - return HasLabel(attribute_id, label_ids=condition["labels"]) + restriction = HasLabel(attribute_id, label_ids=condition["labels"]) case _: raise ValueError(f"未知の制約種別です。 :: _type='{condition_type}'") + return restriction def _ast_to_atomic_restriction(ast: RestrictionAst, *, fac: AttributeFactory, attribute: AttributeDefinition) -> Restriction: From 1c80febd27f7b4ed50f40db3130386db4aff54d4 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 02:36:11 +0900 Subject: [PATCH 30/47] Inline simple restriction helpers --- annofabapi/util/attribute_restrictions.py | 80 ++++++++--------------- 1 file changed, 26 insertions(+), 54 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index 5e2d3551..036411f8 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -853,9 +853,10 @@ def _from_condition_dict(*, attribute_id: str, condition: dict[str, Any]) -> Res return restriction -def _ast_to_atomic_restriction(ast: RestrictionAst, *, fac: AttributeFactory, attribute: AttributeDefinition) -> Restriction: +def _ast_to_atomic_restriction(ast: RestrictionAst, *, fac: AttributeFactory, attribute: AttributeDefinition) -> Restriction: # noqa: PLR0912 assert ast.attribute_name is not None attribute_type = attribute["type"] + restriction: Restriction match ast.type: case RestrictionAstType.CHECKED: @@ -875,11 +876,28 @@ def _ast_to_atomic_restriction(ast: RestrictionAst, *, fac: AttributeFactory, at case RestrictionAstType.MATCHES_STRING | RestrictionAstType.NOT_MATCHES_STRING: restriction = _ast_string_match_to_restriction(ast=ast, fac=fac, attribute=attribute, attribute_type=attribute_type) case RestrictionAstType.EQUALS_INTEGER | RestrictionAstType.NOT_EQUALS_INTEGER: - restriction = _ast_integer_to_restriction(ast=ast, fac=fac) + assert isinstance(ast.value, int) + attribute_obj = fac.integer_textbox(attribute_name=ast.attribute_name) + match ast.type: + case RestrictionAstType.EQUALS_INTEGER: + restriction = attribute_obj.equals(ast.value) + case RestrictionAstType.NOT_EQUALS_INTEGER: + restriction = attribute_obj.not_equals(ast.value) + case _: + raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") case RestrictionAstType.HAS_CHOICE | RestrictionAstType.NOT_HAS_CHOICE: - restriction = _ast_selection_to_restriction(ast=ast, fac=fac) + assert ast.choice_name is not None + attribute_obj = fac.selection(attribute_name=ast.attribute_name) + match ast.type: + case RestrictionAstType.HAS_CHOICE: + restriction = attribute_obj.has_choice(choice_name=ast.choice_name) + case RestrictionAstType.NOT_HAS_CHOICE: + restriction = attribute_obj.not_has_choice(choice_name=ast.choice_name) + case _: + raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") case RestrictionAstType.HAS_LABEL: - restriction = _ast_label_to_restriction(ast=ast, fac=fac) + assert ast.label_names is not None + restriction = fac.annotation_link(attribute_name=ast.attribute_name).has_label(label_names=ast.label_names) case RestrictionAstType.IMPLY: raise AssertionError("`imply`は `_ast_to_restriction` で処理されるため、ここには到達しません。") case _ as never: @@ -934,49 +952,18 @@ def _ast_string_match_to_restriction( raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") -def _ast_integer_to_restriction(*, ast: RestrictionAst, fac: AttributeFactory) -> Restriction: - assert isinstance(ast.value, int) - attribute_obj = fac.integer_textbox(attribute_name=ast.attribute_name) - match ast.type: - case RestrictionAstType.EQUALS_INTEGER: - return attribute_obj.equals(ast.value) - case RestrictionAstType.NOT_EQUALS_INTEGER: - return attribute_obj.not_equals(ast.value) - case _: - raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") - - -def _ast_selection_to_restriction(*, ast: RestrictionAst, fac: AttributeFactory) -> Restriction: - assert ast.choice_name is not None - attribute_obj = fac.selection(attribute_name=ast.attribute_name) - match ast.type: - case RestrictionAstType.HAS_CHOICE: - return attribute_obj.has_choice(choice_name=ast.choice_name) - case RestrictionAstType.NOT_HAS_CHOICE: - return attribute_obj.not_has_choice(choice_name=ast.choice_name) - case _: - raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") - - -def _ast_label_to_restriction(*, ast: RestrictionAst, fac: AttributeFactory) -> Restriction: - assert ast.label_names is not None - return fac.annotation_link(attribute_name=ast.attribute_name).has_label(label_names=ast.label_names) - - -def _create_attribute_object(fac: AttributeFactory, attribute: AttributeDefinition) -> Attribute: +def _create_attribute_object_with_name(fac: AttributeFactory, attribute_name: str) -> Attribute: """ - 属性定義から対応する高水準属性オブジェクトを生成します。 + 属性名から対応する高水準属性オブジェクトを生成します。 Args: fac: 属性生成に使う `AttributeFactory` です。 - attribute: アノテーション仕様上の属性定義です。 + attribute_name: 属性名です。 Returns: 対応する高水準属性オブジェクトです。 - - Raises: - ValueError: 未対応の属性種類が指定された場合 """ + attribute = fac.accessor.get_attribute(attribute_name=attribute_name) attribute_id = attribute["additional_data_definition_id"] attribute_type: AdditionalDataDefinitionType = attribute["type"] match attribute_type: @@ -996,21 +983,6 @@ def _create_attribute_object(fac: AttributeFactory, attribute: AttributeDefiniti raise ValueError(f"未対応の属性種類です。 :: attribute_type='{attribute_type}'") -def _create_attribute_object_with_name(fac: AttributeFactory, attribute_name: str) -> Attribute: - """ - 属性名から対応する高水準属性オブジェクトを生成します。 - - Args: - fac: 属性生成に使う `AttributeFactory` です。 - attribute_name: 属性名です。 - - Returns: - 対応する高水準属性オブジェクトです。 - """ - attribute = fac.accessor.get_attribute(attribute_name=attribute_name) - return _create_attribute_object(fac, attribute) - - def _ast_to_restriction(ast: RestrictionAst, *, fac: AttributeFactory) -> Restriction: """ 意味ベースのASTを `Restriction` オブジェクトへコンパイルします。 From 4a5aa06dd68294b7b1c2dca6fbf08376b3d5fda5 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 02:38:30 +0900 Subject: [PATCH 31/47] Move AST field rules into RestrictionAst --- annofabapi/util/attribute_restrictions.py | 73 +++++++++++------------ 1 file changed, 35 insertions(+), 38 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index 036411f8..ec71ab27 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -425,6 +425,40 @@ class RestrictionAst(BaseModel): premise: "RestrictionAst | None" = Field(default=None, description="`imply` ノードの前提です。") conclusion: "RestrictionAst | None" = Field(default=None, description="`imply` ノードの結論です。") + @classmethod + def _get_required_fields(cls, ast_type: RestrictionAstType) -> set[str]: + """ + AST種別ごとに必須なフィールド名を返します。 + + Args: + ast_type: ASTノードの種類です。 + + Returns: + AST種別に対応する必須フィールド名です。 + """ + match ast_type: + case RestrictionAstType.CHECKED | RestrictionAstType.UNCHECKED | RestrictionAstType.IS_EMPTY | RestrictionAstType.IS_NOT_EMPTY: + return {"attribute_name"} + case ( + RestrictionAstType.EQUALS_STRING + | RestrictionAstType.NOT_EQUALS_STRING + | RestrictionAstType.MATCHES_STRING + | RestrictionAstType.NOT_MATCHES_STRING + | RestrictionAstType.EQUALS_INTEGER + | RestrictionAstType.NOT_EQUALS_INTEGER + ): + return {"attribute_name", "value"} + case RestrictionAstType.HAS_CHOICE | RestrictionAstType.NOT_HAS_CHOICE: + return {"attribute_name", "choice_name"} + case RestrictionAstType.HAS_LABEL: + return {"attribute_name", "label_names"} + case RestrictionAstType.CAN_INPUT: + return {"attribute_name", "enable"} + case RestrictionAstType.IMPLY: + return {"premise", "conclusion"} + case _ as never: + assert_noreturn(never) + @model_validator(mode="after") def validate_restriction_ast(self) -> "RestrictionAst": # noqa: PLR0912 """ @@ -433,7 +467,7 @@ def validate_restriction_ast(self) -> "RestrictionAst": # noqa: PLR0912 Raises: ValueError: AST種別に対して必須フィールドが不足している場合、または型が不正な場合 """ - required_fields = _get_required_ast_fields(self.type) + required_fields = self._get_required_fields(self.type) actual_fields = { field_name for field_name, value in ( @@ -676,43 +710,6 @@ def _from_restriction_dict(obj: dict[str, Any]) -> Restriction: return _from_condition_dict(attribute_id=attribute_id, condition=condition) -def _get_required_ast_fields(ast_type: RestrictionAstType) -> set[str]: - """ - AST種別ごとに必須なフィールド名を返します。 - - Args: - ast_type: ASTノードの種類です。 - - Returns: - AST種別に対応する必須フィールド名です。 - - Raises: - ValueError: 未知のAST種別が指定された場合 - """ - match ast_type: - case RestrictionAstType.CHECKED | RestrictionAstType.UNCHECKED | RestrictionAstType.IS_EMPTY | RestrictionAstType.IS_NOT_EMPTY: - return {"attribute_name"} - case ( - RestrictionAstType.EQUALS_STRING - | RestrictionAstType.NOT_EQUALS_STRING - | RestrictionAstType.MATCHES_STRING - | RestrictionAstType.NOT_MATCHES_STRING - | RestrictionAstType.EQUALS_INTEGER - | RestrictionAstType.NOT_EQUALS_INTEGER - ): - return {"attribute_name", "value"} - case RestrictionAstType.HAS_CHOICE | RestrictionAstType.NOT_HAS_CHOICE: - return {"attribute_name", "choice_name"} - case RestrictionAstType.HAS_LABEL: - return {"attribute_name", "label_names"} - case RestrictionAstType.CAN_INPUT: - return {"attribute_name", "enable"} - case RestrictionAstType.IMPLY: - return {"premise", "conclusion"} - case _ as never: - assert_noreturn(never) - - def _restriction_ast_to_human_readable_text(ast: RestrictionAst, *, attribute_name: str) -> str: # noqa: PLR0912 """ `imply` 以外のASTを人間向けの読みやすい文字列へ変換します。 From d2b36a2b286464c116157c46ddf5a8f41f9d25b5 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 02:40:16 +0900 Subject: [PATCH 32/47] Localize allowed AST type helper --- annofabapi/util/attribute_restrictions.py | 121 +++++++++++----------- 1 file changed, 59 insertions(+), 62 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index ec71ab27..93d62612 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -655,6 +655,64 @@ def get_attribute_restriction_catalog(annotation_specs: dict[str, Any]) -> list[ Returns: LLMへのプロンプトや入力候補生成に使いやすい属性カタログです。 """ + + def get_allowed_ast_types(attribute_type: AdditionalDataDefinitionType) -> list[RestrictionAstType]: + """ + 属性種類ごとに利用可能なAST種別を返します。 + + Args: + attribute_type: アノテーション仕様上の属性種類です。 + + Returns: + 指定した属性種類で利用可能なAST種別の一覧です。 + """ + match attribute_type: + case "flag": + return [RestrictionAstType.CAN_INPUT, RestrictionAstType.CHECKED, RestrictionAstType.UNCHECKED] + case "text" | "comment": + return [ + RestrictionAstType.CAN_INPUT, + RestrictionAstType.IS_EMPTY, + RestrictionAstType.IS_NOT_EMPTY, + RestrictionAstType.EQUALS_STRING, + RestrictionAstType.NOT_EQUALS_STRING, + RestrictionAstType.MATCHES_STRING, + RestrictionAstType.NOT_MATCHES_STRING, + ] + case "integer": + return [ + RestrictionAstType.CAN_INPUT, + RestrictionAstType.IS_EMPTY, + RestrictionAstType.IS_NOT_EMPTY, + RestrictionAstType.EQUALS_INTEGER, + RestrictionAstType.NOT_EQUALS_INTEGER, + ] + case "link": + return [ + RestrictionAstType.CAN_INPUT, + RestrictionAstType.IS_EMPTY, + RestrictionAstType.IS_NOT_EMPTY, + RestrictionAstType.HAS_LABEL, + ] + case "tracking": + return [ + RestrictionAstType.CAN_INPUT, + RestrictionAstType.IS_EMPTY, + RestrictionAstType.IS_NOT_EMPTY, + RestrictionAstType.EQUALS_STRING, + RestrictionAstType.NOT_EQUALS_STRING, + ] + case "choice" | "select": + return [ + RestrictionAstType.CAN_INPUT, + RestrictionAstType.IS_EMPTY, + RestrictionAstType.IS_NOT_EMPTY, + RestrictionAstType.HAS_CHOICE, + RestrictionAstType.NOT_HAS_CHOICE, + ] + case _: + raise ValueError(f"未対応の属性種類です。 :: attribute_type='{attribute_type}'") + accessor = AnnotationSpecsAccessor(annotation_specs) catalog: list[AttributeRestrictionCatalogItem] = [] for attribute in accessor.additionals: @@ -669,7 +727,7 @@ def get_attribute_restriction_catalog(annotation_specs: dict[str, Any]) -> list[ item = AttributeRestrictionCatalogItem( attribute_name=get_english_message(attribute["name"]), attribute_type=attribute_type, - allowed_ast_types=_get_allowed_ast_types(attribute_type), + allowed_ast_types=get_allowed_ast_types(attribute_type), choice_names=choice_names, label_names=label_names, ) @@ -755,67 +813,6 @@ def _restriction_ast_to_human_readable_text(ast: RestrictionAst, *, attribute_na return text -def _get_allowed_ast_types(attribute_type: AdditionalDataDefinitionType) -> list[RestrictionAstType]: - """ - 属性種類ごとに利用可能なAST種別を返します。 - - Args: - attribute_type: アノテーション仕様上の属性種類です。 - - Returns: - 指定した属性種類で利用可能なAST種別の一覧です。 - - Raises: - ValueError: 未対応の属性種類が指定された場合 - """ - match attribute_type: - case "flag": - return [RestrictionAstType.CAN_INPUT, RestrictionAstType.CHECKED, RestrictionAstType.UNCHECKED] - case "text" | "comment": - return [ - RestrictionAstType.CAN_INPUT, - RestrictionAstType.IS_EMPTY, - RestrictionAstType.IS_NOT_EMPTY, - RestrictionAstType.EQUALS_STRING, - RestrictionAstType.NOT_EQUALS_STRING, - RestrictionAstType.MATCHES_STRING, - RestrictionAstType.NOT_MATCHES_STRING, - ] - case "integer": - return [ - RestrictionAstType.CAN_INPUT, - RestrictionAstType.IS_EMPTY, - RestrictionAstType.IS_NOT_EMPTY, - RestrictionAstType.EQUALS_INTEGER, - RestrictionAstType.NOT_EQUALS_INTEGER, - ] - case "link": - return [ - RestrictionAstType.CAN_INPUT, - RestrictionAstType.IS_EMPTY, - RestrictionAstType.IS_NOT_EMPTY, - RestrictionAstType.HAS_LABEL, - ] - case "tracking": - return [ - RestrictionAstType.CAN_INPUT, - RestrictionAstType.IS_EMPTY, - RestrictionAstType.IS_NOT_EMPTY, - RestrictionAstType.EQUALS_STRING, - RestrictionAstType.NOT_EQUALS_STRING, - ] - case "choice" | "select": - return [ - RestrictionAstType.CAN_INPUT, - RestrictionAstType.IS_EMPTY, - RestrictionAstType.IS_NOT_EMPTY, - RestrictionAstType.HAS_CHOICE, - RestrictionAstType.NOT_HAS_CHOICE, - ] - case _: - raise ValueError(f"未対応の属性種類です。 :: attribute_type='{attribute_type}'") - - def _from_condition_dict(*, attribute_id: str, condition: dict[str, Any]) -> Restriction: """ 条件部分の辞書から `Restriction` を復元します。 From 031e2f82ae58c28954f02dd9bb7e632918de95d7 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 02:41:07 +0900 Subject: [PATCH 33/47] Localize human readable AST formatter --- annofabapi/util/attribute_restrictions.py | 93 +++++++++++------------ 1 file changed, 46 insertions(+), 47 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index 93d62612..d1526b8a 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -531,7 +531,7 @@ def to_restriction(self, annotation_specs: dict[str, Any]) -> Restriction: fac = AttributeFactory(annotation_specs) return _ast_to_restriction(self, fac=fac) - def to_human_readable(self) -> str: + def to_human_readable(self) -> str: # noqa: PLR0915 """ ASTを人間向けの読みやすい文字列へ変換します。 @@ -542,6 +542,50 @@ def to_human_readable(self) -> str: ValueError: 未知のAST種別が指定された場合 """ + def to_human_readable_text(ast: RestrictionAst, *, attribute_name: str) -> str: # noqa: PLR0912 + """ + `imply` 以外のASTを人間向けの読みやすい文字列へ変換します。 + + Args: + ast: 変換対象のASTです。 + attribute_name: `repr()` 済みの属性名です。 + + Returns: + 人間向けの読みやすい文字列です。 + """ + match ast.type: + case RestrictionAstType.CHECKED: + text = f"{attribute_name} is checked" + case RestrictionAstType.UNCHECKED: + text = f"{attribute_name} is unchecked" + case RestrictionAstType.IS_EMPTY: + text = f"{attribute_name} is empty" + case RestrictionAstType.IS_NOT_EMPTY: + text = f"{attribute_name} is not empty" + case RestrictionAstType.EQUALS_STRING | RestrictionAstType.EQUALS_INTEGER: + text = f"{attribute_name} is " + repr(ast.value) + case RestrictionAstType.NOT_EQUALS_STRING | RestrictionAstType.NOT_EQUALS_INTEGER: + text = f"{attribute_name} is not " + repr(ast.value) + case RestrictionAstType.MATCHES_STRING: + text = f"{attribute_name} matches " + repr(ast.value) + case RestrictionAstType.NOT_MATCHES_STRING: + text = f"{attribute_name} does not match " + repr(ast.value) + case RestrictionAstType.HAS_CHOICE: + text = f"{attribute_name} is " + repr(ast.choice_name) + case RestrictionAstType.NOT_HAS_CHOICE: + text = f"{attribute_name} is not " + repr(ast.choice_name) + case RestrictionAstType.HAS_LABEL: + assert ast.label_names is not None + text = f"{attribute_name} has labels {', '.join(repr(label_name) for label_name in ast.label_names)}" + case RestrictionAstType.CAN_INPUT: + assert ast.enable is not None + text = f"{attribute_name} can be edited" if ast.enable else f"{attribute_name} is read-only" + case RestrictionAstType.IMPLY: + raise AssertionError("`imply`は事前に処理されるため、ここには到達しません。") + case _ as never: + assert_noreturn(never) + return text + def flatten_imply_conditions(ast: RestrictionAst) -> tuple[list[RestrictionAst], RestrictionAst]: """ 右側にネストした `imply` を条件列と結論へ分解します。 @@ -600,7 +644,7 @@ def imply_to_human_readable(ast: RestrictionAst) -> str: assert self.attribute_name is not None attribute_name = repr(self.attribute_name) - return _restriction_ast_to_human_readable_text(self, attribute_name=attribute_name) + return to_human_readable_text(self, attribute_name=attribute_name) RestrictionAst.model_rebuild() @@ -768,51 +812,6 @@ def _from_restriction_dict(obj: dict[str, Any]) -> Restriction: return _from_condition_dict(attribute_id=attribute_id, condition=condition) -def _restriction_ast_to_human_readable_text(ast: RestrictionAst, *, attribute_name: str) -> str: # noqa: PLR0912 - """ - `imply` 以外のASTを人間向けの読みやすい文字列へ変換します。 - - Args: - ast: 変換対象のASTです。 - attribute_name: `repr()` 済みの属性名です。 - - Returns: - 人間向けの読みやすい文字列です。 - """ - match ast.type: - case RestrictionAstType.CHECKED: - text = f"{attribute_name} is checked" - case RestrictionAstType.UNCHECKED: - text = f"{attribute_name} is unchecked" - case RestrictionAstType.IS_EMPTY: - text = f"{attribute_name} is empty" - case RestrictionAstType.IS_NOT_EMPTY: - text = f"{attribute_name} is not empty" - case RestrictionAstType.EQUALS_STRING | RestrictionAstType.EQUALS_INTEGER: - text = f"{attribute_name} is " + repr(ast.value) - case RestrictionAstType.NOT_EQUALS_STRING | RestrictionAstType.NOT_EQUALS_INTEGER: - text = f"{attribute_name} is not " + repr(ast.value) - case RestrictionAstType.MATCHES_STRING: - text = f"{attribute_name} matches " + repr(ast.value) - case RestrictionAstType.NOT_MATCHES_STRING: - text = f"{attribute_name} does not match " + repr(ast.value) - case RestrictionAstType.HAS_CHOICE: - text = f"{attribute_name} is " + repr(ast.choice_name) - case RestrictionAstType.NOT_HAS_CHOICE: - text = f"{attribute_name} is not " + repr(ast.choice_name) - case RestrictionAstType.HAS_LABEL: - assert ast.label_names is not None - text = f"{attribute_name} has labels {', '.join(repr(label_name) for label_name in ast.label_names)}" - case RestrictionAstType.CAN_INPUT: - assert ast.enable is not None - text = f"{attribute_name} can be edited" if ast.enable else f"{attribute_name} is read-only" - case RestrictionAstType.IMPLY: - raise AssertionError("`imply`は事前に処理されるため、ここには到達しません。") - case _ as never: - assert_noreturn(never) - return text - - def _from_condition_dict(*, attribute_id: str, condition: dict[str, Any]) -> Restriction: """ 条件部分の辞書から `Restriction` を復元します。 From 8ce3c10cb6855f4659b11c878eb192f453f8aa09 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 02:43:18 +0900 Subject: [PATCH 34/47] Localize atomic restriction-to-AST conversion --- annofabapi/util/attribute_restrictions.py | 63 +++++++++++++---------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index d1526b8a..05f3ffde 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -1003,32 +1003,6 @@ def _ast_to_restriction(ast: RestrictionAst, *, fac: AttributeFactory) -> Restri return _ast_to_atomic_restriction(ast, fac=fac, attribute=attribute) -def _restriction_to_atomic_ast( - restriction: Restriction, - *, - accessor: AnnotationSpecsAccessor, - attribute: AttributeDefinition, - attribute_name: str, -) -> RestrictionAst: - attribute_type = attribute["type"] - match restriction: - case CanInput(enable=enable): - return RestrictionAst(type=RestrictionAstType.CAN_INPUT, attribute_name=attribute_name, enable=enable) - case Equals(value=value): - return _equals_restriction_to_ast(attribute=attribute, attribute_name=attribute_name, attribute_type=attribute_type, value=value) - case NotEquals(value=value): - return _not_equals_restriction_to_ast(attribute=attribute, attribute_name=attribute_name, attribute_type=attribute_type, value=value) - case Matches(value=value) if attribute_type in {"text", "comment"}: - return RestrictionAst(type=RestrictionAstType.MATCHES_STRING, attribute_name=attribute_name, value=value) - case NotMatches(value=value) if attribute_type in {"text", "comment"}: - return RestrictionAst(type=RestrictionAstType.NOT_MATCHES_STRING, attribute_name=attribute_name, value=value) - case HasLabel(label_ids=label_ids) if attribute_type == "link": - label_names = [get_english_message(accessor.get_label(label_id=label_id)["label_name"]) for label_id in label_ids] - return RestrictionAst(type=RestrictionAstType.HAS_LABEL, attribute_name=attribute_name, label_names=label_names) - case _: - _raise_invalid_restriction(attribute=attribute, condition=restriction.to_dict()["condition"]) - - def _equals_restriction_to_ast( *, attribute: AttributeDefinition, @@ -1185,6 +1159,41 @@ def _restriction_to_ast(restriction: Restriction, *, accessor: AnnotationSpecsAc Raises: ValueError: ASTへ変換できない制約が含まれている場合 """ + + def restriction_to_atomic_ast( + restriction: Restriction, + *, + attribute: AttributeDefinition, + attribute_name: str, + ) -> RestrictionAst: + attribute_type = attribute["type"] + match restriction: + case CanInput(enable=enable): + return RestrictionAst(type=RestrictionAstType.CAN_INPUT, attribute_name=attribute_name, enable=enable) + case Equals(value=value): + return _equals_restriction_to_ast( + attribute=attribute, + attribute_name=attribute_name, + attribute_type=attribute_type, + value=value, + ) + case NotEquals(value=value): + return _not_equals_restriction_to_ast( + attribute=attribute, + attribute_name=attribute_name, + attribute_type=attribute_type, + value=value, + ) + case Matches(value=value) if attribute_type in {"text", "comment"}: + return RestrictionAst(type=RestrictionAstType.MATCHES_STRING, attribute_name=attribute_name, value=value) + case NotMatches(value=value) if attribute_type in {"text", "comment"}: + return RestrictionAst(type=RestrictionAstType.NOT_MATCHES_STRING, attribute_name=attribute_name, value=value) + case HasLabel(label_ids=label_ids) if attribute_type == "link": + label_names = [get_english_message(accessor.get_label(label_id=label_id)["label_name"]) for label_id in label_ids] + return RestrictionAst(type=RestrictionAstType.HAS_LABEL, attribute_name=attribute_name, label_names=label_names) + case _: + _raise_invalid_restriction(attribute=attribute, condition=restriction.to_dict()["condition"]) + match restriction: case Imply(premise_restriction=premise_restriction, conclusion_restriction=conclusion_restriction): return RestrictionAst( @@ -1195,4 +1204,4 @@ def _restriction_to_ast(restriction: Restriction, *, accessor: AnnotationSpecsAc attribute = accessor.get_attribute(attribute_id=restriction.attribute_id) attribute_name = get_english_message(attribute["name"]) - return _restriction_to_atomic_ast(restriction, accessor=accessor, attribute=attribute, attribute_name=attribute_name) + return restriction_to_atomic_ast(restriction, attribute=attribute, attribute_name=attribute_name) From b630da3d213ff63b41d6d0dfac5c2384f8779039 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 02:44:39 +0900 Subject: [PATCH 35/47] Localize equality restriction-to-AST helpers --- annofabapi/util/attribute_restrictions.py | 128 +++++++++++----------- 1 file changed, 66 insertions(+), 62 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index 05f3ffde..a83e43da 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -1003,66 +1003,6 @@ def _ast_to_restriction(ast: RestrictionAst, *, fac: AttributeFactory) -> Restri return _ast_to_atomic_restriction(ast, fac=fac, attribute=attribute) -def _equals_restriction_to_ast( - *, - attribute: AttributeDefinition, - attribute_name: str, - attribute_type: AdditionalDataDefinitionType, - value: str, -) -> RestrictionAst: - match attribute_type: - case "flag" if value == "true": - return RestrictionAst(type=RestrictionAstType.CHECKED, attribute_name=attribute_name) - case "text" | "comment" | "integer" | "link" | "tracking" | "choice" | "select" if value == "": - return RestrictionAst(type=RestrictionAstType.IS_EMPTY, attribute_name=attribute_name) - case "text" | "comment" | "tracking": - return RestrictionAst(type=RestrictionAstType.EQUALS_STRING, attribute_name=attribute_name, value=value) - case "integer": - return RestrictionAst( - type=RestrictionAstType.EQUALS_INTEGER, - attribute_name=attribute_name, - value=_parse_integer_value(value, attribute=attribute, condition={"_type": "Equals", "value": value}), - ) - case "choice" | "select": - choice = get_choice(_get_attribute_choices(attribute), choice_id=value) - return RestrictionAst(type=RestrictionAstType.HAS_CHOICE, attribute_name=attribute_name, choice_name=get_english_message(choice["name"])) - case _: - raise ValueError(f"RestrictionをASTへ変換できません。 :: restriction_type='Equals', attribute_type='{attribute_type}', value={value!r}") - - -def _not_equals_restriction_to_ast( - *, - attribute: AttributeDefinition, - attribute_name: str, - attribute_type: AdditionalDataDefinitionType, - value: str, -) -> RestrictionAst: - match attribute_type: - case "flag" if value == "true": - return RestrictionAst(type=RestrictionAstType.UNCHECKED, attribute_name=attribute_name) - case "text" | "comment" | "integer" | "link" | "tracking" | "choice" | "select" if value == "": - return RestrictionAst(type=RestrictionAstType.IS_NOT_EMPTY, attribute_name=attribute_name) - case "text" | "comment" | "tracking": - return RestrictionAst(type=RestrictionAstType.NOT_EQUALS_STRING, attribute_name=attribute_name, value=value) - case "integer": - return RestrictionAst( - type=RestrictionAstType.NOT_EQUALS_INTEGER, - attribute_name=attribute_name, - value=_parse_integer_value(value, attribute=attribute, condition={"_type": "NotEquals", "value": value}), - ) - case "choice" | "select": - choice = get_choice(_get_attribute_choices(attribute), choice_id=value) - return RestrictionAst( - type=RestrictionAstType.NOT_HAS_CHOICE, - attribute_name=attribute_name, - choice_name=get_english_message(choice["name"]), - ) - case _: - raise ValueError( - f"RestrictionをASTへ変換できません。 :: restriction_type='NotEquals', attribute_type='{attribute_type}', value={value!r}" - ) - - def _attribute_with_empty_check(fac: AttributeFactory, attribute_name: str) -> EmptyCheckMixin: """ 空判定をサポートする属性オブジェクトを取得します。 @@ -1160,6 +1100,70 @@ def _restriction_to_ast(restriction: Restriction, *, accessor: AnnotationSpecsAc ValueError: ASTへ変換できない制約が含まれている場合 """ + def equals_restriction_to_ast( + *, + attribute: AttributeDefinition, + attribute_name: str, + attribute_type: AdditionalDataDefinitionType, + value: str, + ) -> RestrictionAst: + match attribute_type: + case "flag" if value == "true": + return RestrictionAst(type=RestrictionAstType.CHECKED, attribute_name=attribute_name) + case "text" | "comment" | "integer" | "link" | "tracking" | "choice" | "select" if value == "": + return RestrictionAst(type=RestrictionAstType.IS_EMPTY, attribute_name=attribute_name) + case "text" | "comment" | "tracking": + return RestrictionAst(type=RestrictionAstType.EQUALS_STRING, attribute_name=attribute_name, value=value) + case "integer": + return RestrictionAst( + type=RestrictionAstType.EQUALS_INTEGER, + attribute_name=attribute_name, + value=_parse_integer_value(value, attribute=attribute, condition={"_type": "Equals", "value": value}), + ) + case "choice" | "select": + choice = get_choice(_get_attribute_choices(attribute), choice_id=value) + return RestrictionAst( + type=RestrictionAstType.HAS_CHOICE, + attribute_name=attribute_name, + choice_name=get_english_message(choice["name"]), + ) + case _: + raise ValueError( + f"RestrictionをASTへ変換できません。 :: restriction_type='Equals', attribute_type='{attribute_type}', value={value!r}" + ) + + def not_equals_restriction_to_ast( + *, + attribute: AttributeDefinition, + attribute_name: str, + attribute_type: AdditionalDataDefinitionType, + value: str, + ) -> RestrictionAst: + match attribute_type: + case "flag" if value == "true": + return RestrictionAst(type=RestrictionAstType.UNCHECKED, attribute_name=attribute_name) + case "text" | "comment" | "integer" | "link" | "tracking" | "choice" | "select" if value == "": + return RestrictionAst(type=RestrictionAstType.IS_NOT_EMPTY, attribute_name=attribute_name) + case "text" | "comment" | "tracking": + return RestrictionAst(type=RestrictionAstType.NOT_EQUALS_STRING, attribute_name=attribute_name, value=value) + case "integer": + return RestrictionAst( + type=RestrictionAstType.NOT_EQUALS_INTEGER, + attribute_name=attribute_name, + value=_parse_integer_value(value, attribute=attribute, condition={"_type": "NotEquals", "value": value}), + ) + case "choice" | "select": + choice = get_choice(_get_attribute_choices(attribute), choice_id=value) + return RestrictionAst( + type=RestrictionAstType.NOT_HAS_CHOICE, + attribute_name=attribute_name, + choice_name=get_english_message(choice["name"]), + ) + case _: + raise ValueError( + f"RestrictionをASTへ変換できません。 :: restriction_type='NotEquals', attribute_type='{attribute_type}', value={value!r}" + ) + def restriction_to_atomic_ast( restriction: Restriction, *, @@ -1171,14 +1175,14 @@ def restriction_to_atomic_ast( case CanInput(enable=enable): return RestrictionAst(type=RestrictionAstType.CAN_INPUT, attribute_name=attribute_name, enable=enable) case Equals(value=value): - return _equals_restriction_to_ast( + return equals_restriction_to_ast( attribute=attribute, attribute_name=attribute_name, attribute_type=attribute_type, value=value, ) case NotEquals(value=value): - return _not_equals_restriction_to_ast( + return not_equals_restriction_to_ast( attribute=attribute, attribute_name=attribute_name, attribute_type=attribute_type, From 8dd879bfde2dfaa4bf3c65b00de417c3f984d712 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 02:46:47 +0900 Subject: [PATCH 36/47] Localize atomic AST-to-restriction conversion --- annofabapi/util/attribute_restrictions.py | 108 +++++++++++----------- 1 file changed, 54 insertions(+), 54 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index a83e43da..2e4e599d 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -846,58 +846,6 @@ def _from_condition_dict(*, attribute_id: str, condition: dict[str, Any]) -> Res return restriction -def _ast_to_atomic_restriction(ast: RestrictionAst, *, fac: AttributeFactory, attribute: AttributeDefinition) -> Restriction: # noqa: PLR0912 - assert ast.attribute_name is not None - attribute_type = attribute["type"] - restriction: Restriction - - match ast.type: - case RestrictionAstType.CHECKED: - restriction = fac.checkbox(attribute_name=ast.attribute_name).checked() - case RestrictionAstType.UNCHECKED: - restriction = fac.checkbox(attribute_name=ast.attribute_name).unchecked() - case RestrictionAstType.IS_EMPTY: - restriction = _attribute_with_empty_check(fac, ast.attribute_name).is_empty() - case RestrictionAstType.IS_NOT_EMPTY: - restriction = _attribute_with_empty_check(fac, ast.attribute_name).is_not_empty() - case RestrictionAstType.CAN_INPUT: - assert ast.enable is not None - attribute_obj = _create_attribute_object_with_name(fac, ast.attribute_name) - restriction = attribute_obj.enabled() if ast.enable else attribute_obj.disabled() - case RestrictionAstType.EQUALS_STRING | RestrictionAstType.NOT_EQUALS_STRING: - restriction = _ast_string_equality_to_restriction(ast=ast, fac=fac, attribute=attribute, attribute_type=attribute_type) - case RestrictionAstType.MATCHES_STRING | RestrictionAstType.NOT_MATCHES_STRING: - restriction = _ast_string_match_to_restriction(ast=ast, fac=fac, attribute=attribute, attribute_type=attribute_type) - case RestrictionAstType.EQUALS_INTEGER | RestrictionAstType.NOT_EQUALS_INTEGER: - assert isinstance(ast.value, int) - attribute_obj = fac.integer_textbox(attribute_name=ast.attribute_name) - match ast.type: - case RestrictionAstType.EQUALS_INTEGER: - restriction = attribute_obj.equals(ast.value) - case RestrictionAstType.NOT_EQUALS_INTEGER: - restriction = attribute_obj.not_equals(ast.value) - case _: - raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") - case RestrictionAstType.HAS_CHOICE | RestrictionAstType.NOT_HAS_CHOICE: - assert ast.choice_name is not None - attribute_obj = fac.selection(attribute_name=ast.attribute_name) - match ast.type: - case RestrictionAstType.HAS_CHOICE: - restriction = attribute_obj.has_choice(choice_name=ast.choice_name) - case RestrictionAstType.NOT_HAS_CHOICE: - restriction = attribute_obj.not_has_choice(choice_name=ast.choice_name) - case _: - raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") - case RestrictionAstType.HAS_LABEL: - assert ast.label_names is not None - restriction = fac.annotation_link(attribute_name=ast.attribute_name).has_label(label_names=ast.label_names) - case RestrictionAstType.IMPLY: - raise AssertionError("`imply`は `_ast_to_restriction` で処理されるため、ここには到達しません。") - case _ as never: - assert_noreturn(never) - return restriction - - def _ast_string_equality_to_restriction( *, ast: RestrictionAst, @@ -976,7 +924,7 @@ def _create_attribute_object_with_name(fac: AttributeFactory, attribute_name: st raise ValueError(f"未対応の属性種類です。 :: attribute_type='{attribute_type}'") -def _ast_to_restriction(ast: RestrictionAst, *, fac: AttributeFactory) -> Restriction: +def _ast_to_restriction(ast: RestrictionAst, *, fac: AttributeFactory) -> Restriction: # noqa: PLR0915 """ 意味ベースのASTを `Restriction` オブジェクトへコンパイルします。 @@ -990,6 +938,58 @@ def _ast_to_restriction(ast: RestrictionAst, *, fac: AttributeFactory) -> Restri Raises: ValueError: AST種別が未知の場合、または属性型に対して利用できないASTが指定された場合 """ + + def ast_to_atomic_restriction(ast: RestrictionAst, *, attribute: AttributeDefinition) -> Restriction: # noqa: PLR0912 + assert ast.attribute_name is not None + attribute_type = attribute["type"] + restriction: Restriction + + match ast.type: + case RestrictionAstType.CHECKED: + restriction = fac.checkbox(attribute_name=ast.attribute_name).checked() + case RestrictionAstType.UNCHECKED: + restriction = fac.checkbox(attribute_name=ast.attribute_name).unchecked() + case RestrictionAstType.IS_EMPTY: + restriction = _attribute_with_empty_check(fac, ast.attribute_name).is_empty() + case RestrictionAstType.IS_NOT_EMPTY: + restriction = _attribute_with_empty_check(fac, ast.attribute_name).is_not_empty() + case RestrictionAstType.CAN_INPUT: + assert ast.enable is not None + attribute_obj = _create_attribute_object_with_name(fac, ast.attribute_name) + restriction = attribute_obj.enabled() if ast.enable else attribute_obj.disabled() + case RestrictionAstType.EQUALS_STRING | RestrictionAstType.NOT_EQUALS_STRING: + restriction = _ast_string_equality_to_restriction(ast=ast, fac=fac, attribute=attribute, attribute_type=attribute_type) + case RestrictionAstType.MATCHES_STRING | RestrictionAstType.NOT_MATCHES_STRING: + restriction = _ast_string_match_to_restriction(ast=ast, fac=fac, attribute=attribute, attribute_type=attribute_type) + case RestrictionAstType.EQUALS_INTEGER | RestrictionAstType.NOT_EQUALS_INTEGER: + assert isinstance(ast.value, int) + attribute_obj = fac.integer_textbox(attribute_name=ast.attribute_name) + match ast.type: + case RestrictionAstType.EQUALS_INTEGER: + restriction = attribute_obj.equals(ast.value) + case RestrictionAstType.NOT_EQUALS_INTEGER: + restriction = attribute_obj.not_equals(ast.value) + case _: + raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") + case RestrictionAstType.HAS_CHOICE | RestrictionAstType.NOT_HAS_CHOICE: + assert ast.choice_name is not None + attribute_obj = fac.selection(attribute_name=ast.attribute_name) + match ast.type: + case RestrictionAstType.HAS_CHOICE: + restriction = attribute_obj.has_choice(choice_name=ast.choice_name) + case RestrictionAstType.NOT_HAS_CHOICE: + restriction = attribute_obj.not_has_choice(choice_name=ast.choice_name) + case _: + raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") + case RestrictionAstType.HAS_LABEL: + assert ast.label_names is not None + restriction = fac.annotation_link(attribute_name=ast.attribute_name).has_label(label_names=ast.label_names) + case RestrictionAstType.IMPLY: + raise AssertionError("`imply`は `_ast_to_restriction` で処理されるため、ここには到達しません。") + case _ as never: + assert_noreturn(never) + return restriction + match ast.type: case RestrictionAstType.IMPLY: assert ast.premise is not None @@ -1000,7 +1000,7 @@ def _ast_to_restriction(ast: RestrictionAst, *, fac: AttributeFactory) -> Restri assert ast.attribute_name is not None attribute = fac.accessor.get_attribute(attribute_name=ast.attribute_name) - return _ast_to_atomic_restriction(ast, fac=fac, attribute=attribute) + return ast_to_atomic_restriction(ast, attribute=attribute) def _attribute_with_empty_check(fac: AttributeFactory, attribute_name: str) -> EmptyCheckMixin: From 14d0145b019c2869419d0c5b5d3929bcde5e675a Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 02:48:59 +0900 Subject: [PATCH 37/47] Localize string AST-to-restriction helpers --- annofabapi/util/attribute_restrictions.py | 94 +++++++++++------------ 1 file changed, 45 insertions(+), 49 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index 2e4e599d..42b4f13a 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -846,53 +846,6 @@ def _from_condition_dict(*, attribute_id: str, condition: dict[str, Any]) -> Res return restriction -def _ast_string_equality_to_restriction( - *, - ast: RestrictionAst, - fac: AttributeFactory, - attribute: AttributeDefinition, - attribute_type: AdditionalDataDefinitionType, -) -> Restriction: - assert isinstance(ast.value, str) - attribute_obj: StringTextbox | TrackingId - match attribute_type: - case "text" | "comment": - attribute_obj = fac.string_textbox(attribute_name=ast.attribute_name) - case "tracking": - attribute_obj = fac.tracking_id(attribute_name=ast.attribute_name) - case _: - _raise_invalid_ast(attribute=attribute, ast=ast) - - match ast.type: - case RestrictionAstType.EQUALS_STRING: - return attribute_obj.equals(ast.value) - case RestrictionAstType.NOT_EQUALS_STRING: - return attribute_obj.not_equals(ast.value) - case _: - raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") - - -def _ast_string_match_to_restriction( - *, - ast: RestrictionAst, - fac: AttributeFactory, - attribute: AttributeDefinition, - attribute_type: AdditionalDataDefinitionType, -) -> Restriction: - assert isinstance(ast.value, str) - if attribute_type not in {"text", "comment"}: - _raise_invalid_ast(attribute=attribute, ast=ast) - - attribute_obj = fac.string_textbox(attribute_name=ast.attribute_name) - match ast.type: - case RestrictionAstType.MATCHES_STRING: - return attribute_obj.matches(ast.value) - case RestrictionAstType.NOT_MATCHES_STRING: - return attribute_obj.not_matches(ast.value) - case _: - raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") - - def _create_attribute_object_with_name(fac: AttributeFactory, attribute_name: str) -> Attribute: """ 属性名から対応する高水準属性オブジェクトを生成します。 @@ -939,6 +892,49 @@ def _ast_to_restriction(ast: RestrictionAst, *, fac: AttributeFactory) -> Restri ValueError: AST種別が未知の場合、または属性型に対して利用できないASTが指定された場合 """ + def ast_string_equality_to_restriction( + *, + ast: RestrictionAst, + attribute: AttributeDefinition, + attribute_type: AdditionalDataDefinitionType, + ) -> Restriction: + assert isinstance(ast.value, str) + attribute_obj: StringTextbox | TrackingId + match attribute_type: + case "text" | "comment": + attribute_obj = fac.string_textbox(attribute_name=ast.attribute_name) + case "tracking": + attribute_obj = fac.tracking_id(attribute_name=ast.attribute_name) + case _: + _raise_invalid_ast(attribute=attribute, ast=ast) + + match ast.type: + case RestrictionAstType.EQUALS_STRING: + return attribute_obj.equals(ast.value) + case RestrictionAstType.NOT_EQUALS_STRING: + return attribute_obj.not_equals(ast.value) + case _: + raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") + + def ast_string_match_to_restriction( + *, + ast: RestrictionAst, + attribute: AttributeDefinition, + attribute_type: AdditionalDataDefinitionType, + ) -> Restriction: + assert isinstance(ast.value, str) + if attribute_type not in {"text", "comment"}: + _raise_invalid_ast(attribute=attribute, ast=ast) + + attribute_obj = fac.string_textbox(attribute_name=ast.attribute_name) + match ast.type: + case RestrictionAstType.MATCHES_STRING: + return attribute_obj.matches(ast.value) + case RestrictionAstType.NOT_MATCHES_STRING: + return attribute_obj.not_matches(ast.value) + case _: + raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") + def ast_to_atomic_restriction(ast: RestrictionAst, *, attribute: AttributeDefinition) -> Restriction: # noqa: PLR0912 assert ast.attribute_name is not None attribute_type = attribute["type"] @@ -958,9 +954,9 @@ def ast_to_atomic_restriction(ast: RestrictionAst, *, attribute: AttributeDefini attribute_obj = _create_attribute_object_with_name(fac, ast.attribute_name) restriction = attribute_obj.enabled() if ast.enable else attribute_obj.disabled() case RestrictionAstType.EQUALS_STRING | RestrictionAstType.NOT_EQUALS_STRING: - restriction = _ast_string_equality_to_restriction(ast=ast, fac=fac, attribute=attribute, attribute_type=attribute_type) + restriction = ast_string_equality_to_restriction(ast=ast, attribute=attribute, attribute_type=attribute_type) case RestrictionAstType.MATCHES_STRING | RestrictionAstType.NOT_MATCHES_STRING: - restriction = _ast_string_match_to_restriction(ast=ast, fac=fac, attribute=attribute, attribute_type=attribute_type) + restriction = ast_string_match_to_restriction(ast=ast, attribute=attribute, attribute_type=attribute_type) case RestrictionAstType.EQUALS_INTEGER | RestrictionAstType.NOT_EQUALS_INTEGER: assert isinstance(ast.value, int) attribute_obj = fac.integer_textbox(attribute_name=ast.attribute_name) From f53d3a97dc5b9bcf537338ceb6191edcd9470824 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 02:55:34 +0900 Subject: [PATCH 38/47] Refactor attribute restriction helper inputs --- annofabapi/util/attribute_restrictions.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index 42b4f13a..ae6125b8 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -846,18 +846,17 @@ def _from_condition_dict(*, attribute_id: str, condition: dict[str, Any]) -> Res return restriction -def _create_attribute_object_with_name(fac: AttributeFactory, attribute_name: str) -> Attribute: +def _create_attribute_object(fac: AttributeFactory, attribute: AttributeDefinition) -> Attribute: """ - 属性名から対応する高水準属性オブジェクトを生成します。 + 属性定義から対応する高水準属性オブジェクトを生成します。 Args: fac: 属性生成に使う `AttributeFactory` です。 - attribute_name: 属性名です。 + attribute: アノテーション仕様上の属性定義です。 Returns: 対応する高水準属性オブジェクトです。 """ - attribute = fac.accessor.get_attribute(attribute_name=attribute_name) attribute_id = attribute["additional_data_definition_id"] attribute_type: AdditionalDataDefinitionType = attribute["type"] match attribute_type: @@ -946,12 +945,12 @@ def ast_to_atomic_restriction(ast: RestrictionAst, *, attribute: AttributeDefini case RestrictionAstType.UNCHECKED: restriction = fac.checkbox(attribute_name=ast.attribute_name).unchecked() case RestrictionAstType.IS_EMPTY: - restriction = _attribute_with_empty_check(fac, ast.attribute_name).is_empty() + restriction = _attribute_with_empty_check(fac, attribute=attribute).is_empty() case RestrictionAstType.IS_NOT_EMPTY: - restriction = _attribute_with_empty_check(fac, ast.attribute_name).is_not_empty() + restriction = _attribute_with_empty_check(fac, attribute=attribute).is_not_empty() case RestrictionAstType.CAN_INPUT: assert ast.enable is not None - attribute_obj = _create_attribute_object_with_name(fac, ast.attribute_name) + attribute_obj = _create_attribute_object(fac, attribute=attribute) restriction = attribute_obj.enabled() if ast.enable else attribute_obj.disabled() case RestrictionAstType.EQUALS_STRING | RestrictionAstType.NOT_EQUALS_STRING: restriction = ast_string_equality_to_restriction(ast=ast, attribute=attribute, attribute_type=attribute_type) @@ -999,13 +998,13 @@ def ast_to_atomic_restriction(ast: RestrictionAst, *, attribute: AttributeDefini return ast_to_atomic_restriction(ast, attribute=attribute) -def _attribute_with_empty_check(fac: AttributeFactory, attribute_name: str) -> EmptyCheckMixin: +def _attribute_with_empty_check(fac: AttributeFactory, *, attribute: AttributeDefinition) -> EmptyCheckMixin: """ 空判定をサポートする属性オブジェクトを取得します。 Args: fac: 属性生成に使う `AttributeFactory` です。 - attribute_name: 属性名です。 + attribute: アノテーション仕様上の属性定義です。 Returns: `is_empty()` / `is_not_empty()` を持つ属性オブジェクトです。 @@ -1013,9 +1012,8 @@ def _attribute_with_empty_check(fac: AttributeFactory, attribute_name: str) -> E Raises: ValueError: 指定した属性で空判定を利用できない場合 """ - attribute_obj = _create_attribute_object_with_name(fac, attribute_name) + attribute_obj = _create_attribute_object(fac, attribute) if not isinstance(attribute_obj, EmptyCheckMixin): - attribute = fac.accessor.get_attribute(attribute_name=attribute_name) _raise_invalid_restriction( attribute=attribute, condition={"_type": "EmptyCheck"}, From 303fc72554f3db45e6fed73abc77c9f1a6f09e78 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 03:02:30 +0900 Subject: [PATCH 39/47] Add public attribute factory method --- annofabapi/util/attribute_restrictions.py | 62 +++++++++++------------ tests/util/test_attribute_restrictions.py | 9 ++++ 2 files changed, 39 insertions(+), 32 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index ae6125b8..28c59444 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -404,6 +404,34 @@ def tracking_id(self, *, attribute_id: str | None = None, attribute_name: str | def selection(self, *, attribute_id: str | None = None, attribute_name: str | None = None) -> Selection: return Selection(self.accessor, attribute_id=attribute_id, attribute_name=attribute_name) + def from_definition(self, attribute: AttributeDefinition) -> Attribute: + """ + 属性定義から対応する高水準属性オブジェクトを生成します。 + + Args: + attribute: アノテーション仕様上の属性定義です。 + + Returns: + 対応する高水準属性オブジェクトです。 + """ + attribute_id = attribute["additional_data_definition_id"] + attribute_type: AdditionalDataDefinitionType = attribute["type"] + match attribute_type: + case "flag": + return self.checkbox(attribute_id=attribute_id) + case "text" | "comment": + return self.string_textbox(attribute_id=attribute_id) + case "integer": + return self.integer_textbox(attribute_id=attribute_id) + case "link": + return self.annotation_link(attribute_id=attribute_id) + case "tracking": + return self.tracking_id(attribute_id=attribute_id) + case "choice" | "select": + return self.selection(attribute_id=attribute_id) + case _: + raise ValueError(f"未対応の属性種類です。 :: attribute_type='{attribute_type}'") + class RestrictionAst(BaseModel): """ @@ -846,36 +874,6 @@ def _from_condition_dict(*, attribute_id: str, condition: dict[str, Any]) -> Res return restriction -def _create_attribute_object(fac: AttributeFactory, attribute: AttributeDefinition) -> Attribute: - """ - 属性定義から対応する高水準属性オブジェクトを生成します。 - - Args: - fac: 属性生成に使う `AttributeFactory` です。 - attribute: アノテーション仕様上の属性定義です。 - - Returns: - 対応する高水準属性オブジェクトです。 - """ - attribute_id = attribute["additional_data_definition_id"] - attribute_type: AdditionalDataDefinitionType = attribute["type"] - match attribute_type: - case "flag": - return fac.checkbox(attribute_id=attribute_id) - case "text" | "comment": - return fac.string_textbox(attribute_id=attribute_id) - case "integer": - return fac.integer_textbox(attribute_id=attribute_id) - case "link": - return fac.annotation_link(attribute_id=attribute_id) - case "tracking": - return fac.tracking_id(attribute_id=attribute_id) - case "choice" | "select": - return fac.selection(attribute_id=attribute_id) - case _: - raise ValueError(f"未対応の属性種類です。 :: attribute_type='{attribute_type}'") - - def _ast_to_restriction(ast: RestrictionAst, *, fac: AttributeFactory) -> Restriction: # noqa: PLR0915 """ 意味ベースのASTを `Restriction` オブジェクトへコンパイルします。 @@ -950,7 +948,7 @@ def ast_to_atomic_restriction(ast: RestrictionAst, *, attribute: AttributeDefini restriction = _attribute_with_empty_check(fac, attribute=attribute).is_not_empty() case RestrictionAstType.CAN_INPUT: assert ast.enable is not None - attribute_obj = _create_attribute_object(fac, attribute=attribute) + attribute_obj = fac.from_definition(attribute) restriction = attribute_obj.enabled() if ast.enable else attribute_obj.disabled() case RestrictionAstType.EQUALS_STRING | RestrictionAstType.NOT_EQUALS_STRING: restriction = ast_string_equality_to_restriction(ast=ast, attribute=attribute, attribute_type=attribute_type) @@ -1012,7 +1010,7 @@ def _attribute_with_empty_check(fac: AttributeFactory, *, attribute: AttributeDe Raises: ValueError: 指定した属性で空判定を利用できない場合 """ - attribute_obj = _create_attribute_object(fac, attribute) + attribute_obj = fac.from_definition(attribute) if not isinstance(attribute_obj, EmptyCheckMixin): _raise_invalid_restriction( attribute=attribute, diff --git a/tests/util/test_attribute_restrictions.py b/tests/util/test_attribute_restrictions.py index d985c239..120740ce 100644 --- a/tests/util/test_attribute_restrictions.py +++ b/tests/util/test_attribute_restrictions.py @@ -181,6 +181,7 @@ class Test__AttributeFactory: def setup_method(self): self.annotation_specs = json.loads(Path("tests/data/util/attribute_restrictions/annotation_specs.json").read_text()) self.factory = AttributeFactory(self.annotation_specs) + self.accessor = AnnotationSpecsAccessor(self.annotation_specs) def test__checkbox(self): checkbox = self.factory.checkbox(attribute_name="occluded") @@ -212,6 +213,14 @@ def test__selection(self): assert isinstance(selection, Selection) assert selection.attribute_id == "cbb0155f-1631-48e1-8fc3-43c5f254b6f2" + def test__from_definition(self): + attribute = self.accessor.get_attribute(attribute_name="note") + + actual = self.factory.from_definition(attribute) + + assert isinstance(actual, StringTextbox) + assert actual.attribute_id == "9b05648d-1e16-4ea2-ab79-48907f5eed00" + class Test__Restriction: def test__from_dict(self): From 9c803ff1596038d2eebd3449760e9ae2ed7ea692 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 03:07:10 +0900 Subject: [PATCH 40/47] Use create as attribute factory API --- annofabapi/util/attribute_restrictions.py | 32 ++++++++++++++--------- tests/util/test_attribute_restrictions.py | 8 +++--- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index 28c59444..87ecfb60 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -404,31 +404,37 @@ def tracking_id(self, *, attribute_id: str | None = None, attribute_name: str | def selection(self, *, attribute_id: str | None = None, attribute_name: str | None = None) -> Selection: return Selection(self.accessor, attribute_id=attribute_id, attribute_name=attribute_name) - def from_definition(self, attribute: AttributeDefinition) -> Attribute: + def create( + self, + attribute_type: AdditionalDataDefinitionType, + *, + attribute_id: str | None = None, + attribute_name: str | None = None, + ) -> Attribute: """ - 属性定義から対応する高水準属性オブジェクトを生成します。 + 属性の種類に応じて対応する高水準属性オブジェクトを生成します。 Args: - attribute: アノテーション仕様上の属性定義です。 + attribute_type: 属性の種類です。 + attribute_id: 属性IDです。 + attribute_name: 属性名(英語)です。 Returns: 対応する高水準属性オブジェクトです。 """ - attribute_id = attribute["additional_data_definition_id"] - attribute_type: AdditionalDataDefinitionType = attribute["type"] match attribute_type: case "flag": - return self.checkbox(attribute_id=attribute_id) + return self.checkbox(attribute_id=attribute_id, attribute_name=attribute_name) case "text" | "comment": - return self.string_textbox(attribute_id=attribute_id) + return self.string_textbox(attribute_id=attribute_id, attribute_name=attribute_name) case "integer": - return self.integer_textbox(attribute_id=attribute_id) + return self.integer_textbox(attribute_id=attribute_id, attribute_name=attribute_name) case "link": - return self.annotation_link(attribute_id=attribute_id) + return self.annotation_link(attribute_id=attribute_id, attribute_name=attribute_name) case "tracking": - return self.tracking_id(attribute_id=attribute_id) + return self.tracking_id(attribute_id=attribute_id, attribute_name=attribute_name) case "choice" | "select": - return self.selection(attribute_id=attribute_id) + return self.selection(attribute_id=attribute_id, attribute_name=attribute_name) case _: raise ValueError(f"未対応の属性種類です。 :: attribute_type='{attribute_type}'") @@ -948,7 +954,7 @@ def ast_to_atomic_restriction(ast: RestrictionAst, *, attribute: AttributeDefini restriction = _attribute_with_empty_check(fac, attribute=attribute).is_not_empty() case RestrictionAstType.CAN_INPUT: assert ast.enable is not None - attribute_obj = fac.from_definition(attribute) + attribute_obj = fac.create(attribute["type"], attribute_name=ast.attribute_name) restriction = attribute_obj.enabled() if ast.enable else attribute_obj.disabled() case RestrictionAstType.EQUALS_STRING | RestrictionAstType.NOT_EQUALS_STRING: restriction = ast_string_equality_to_restriction(ast=ast, attribute=attribute, attribute_type=attribute_type) @@ -1010,7 +1016,7 @@ def _attribute_with_empty_check(fac: AttributeFactory, *, attribute: AttributeDe Raises: ValueError: 指定した属性で空判定を利用できない場合 """ - attribute_obj = fac.from_definition(attribute) + attribute_obj = fac.create(attribute["type"], attribute_id=attribute["additional_data_definition_id"]) if not isinstance(attribute_obj, EmptyCheckMixin): _raise_invalid_restriction( attribute=attribute, diff --git a/tests/util/test_attribute_restrictions.py b/tests/util/test_attribute_restrictions.py index 120740ce..2e642024 100644 --- a/tests/util/test_attribute_restrictions.py +++ b/tests/util/test_attribute_restrictions.py @@ -4,6 +4,7 @@ import pytest from pydantic import ValidationError +from annofabapi.pydantic_models.additional_data_definition_type import AdditionalDataDefinitionType from annofabapi.util.annotation_specs import AnnotationSpecsAccessor from annofabapi.util.attribute_restrictions import ( AnnotationLink, @@ -181,7 +182,6 @@ class Test__AttributeFactory: def setup_method(self): self.annotation_specs = json.loads(Path("tests/data/util/attribute_restrictions/annotation_specs.json").read_text()) self.factory = AttributeFactory(self.annotation_specs) - self.accessor = AnnotationSpecsAccessor(self.annotation_specs) def test__checkbox(self): checkbox = self.factory.checkbox(attribute_name="occluded") @@ -213,10 +213,8 @@ def test__selection(self): assert isinstance(selection, Selection) assert selection.attribute_id == "cbb0155f-1631-48e1-8fc3-43c5f254b6f2" - def test__from_definition(self): - attribute = self.accessor.get_attribute(attribute_name="note") - - actual = self.factory.from_definition(attribute) + def test__create(self): + actual = self.factory.create(AdditionalDataDefinitionType.TEXT, attribute_name="note") assert isinstance(actual, StringTextbox) assert actual.attribute_id == "9b05648d-1e16-4ea2-ab79-48907f5eed00" From 3dd9801b8d0616400d586f8f0a2a21b2f8785585 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 03:11:07 +0900 Subject: [PATCH 41/47] Inline empty-check helper in AST conversion --- annofabapi/util/attribute_restrictions.py | 52 +++++++++++------------ 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index 87ecfb60..f3da861a 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -895,6 +895,29 @@ def _ast_to_restriction(ast: RestrictionAst, *, fac: AttributeFactory) -> Restri ValueError: AST種別が未知の場合、または属性型に対して利用できないASTが指定された場合 """ + def attribute_with_empty_check(*, attribute: AttributeDefinition) -> EmptyCheckMixin: + """ + 空判定をサポートする属性オブジェクトを取得します。 + + Args: + attribute: アノテーション仕様上の属性定義です。 + + Returns: + `is_empty()` / `is_not_empty()` を持つ属性オブジェクトです。 + + Raises: + ValueError: 指定した属性で空判定を利用できない場合 + """ + attribute_obj = fac.create(attribute["type"], attribute_id=attribute["additional_data_definition_id"]) + if not isinstance(attribute_obj, EmptyCheckMixin): + _raise_invalid_restriction( + attribute=attribute, + condition={"_type": "EmptyCheck"}, + detail="空判定はこの属性種類では利用できません。", + ) + assert isinstance(attribute_obj, EmptyCheckMixin) + return attribute_obj + def ast_string_equality_to_restriction( *, ast: RestrictionAst, @@ -949,9 +972,9 @@ def ast_to_atomic_restriction(ast: RestrictionAst, *, attribute: AttributeDefini case RestrictionAstType.UNCHECKED: restriction = fac.checkbox(attribute_name=ast.attribute_name).unchecked() case RestrictionAstType.IS_EMPTY: - restriction = _attribute_with_empty_check(fac, attribute=attribute).is_empty() + restriction = attribute_with_empty_check(attribute=attribute).is_empty() case RestrictionAstType.IS_NOT_EMPTY: - restriction = _attribute_with_empty_check(fac, attribute=attribute).is_not_empty() + restriction = attribute_with_empty_check(attribute=attribute).is_not_empty() case RestrictionAstType.CAN_INPUT: assert ast.enable is not None attribute_obj = fac.create(attribute["type"], attribute_name=ast.attribute_name) @@ -1002,31 +1025,6 @@ def ast_to_atomic_restriction(ast: RestrictionAst, *, attribute: AttributeDefini return ast_to_atomic_restriction(ast, attribute=attribute) -def _attribute_with_empty_check(fac: AttributeFactory, *, attribute: AttributeDefinition) -> EmptyCheckMixin: - """ - 空判定をサポートする属性オブジェクトを取得します。 - - Args: - fac: 属性生成に使う `AttributeFactory` です。 - attribute: アノテーション仕様上の属性定義です。 - - Returns: - `is_empty()` / `is_not_empty()` を持つ属性オブジェクトです。 - - Raises: - ValueError: 指定した属性で空判定を利用できない場合 - """ - attribute_obj = fac.create(attribute["type"], attribute_id=attribute["additional_data_definition_id"]) - if not isinstance(attribute_obj, EmptyCheckMixin): - _raise_invalid_restriction( - attribute=attribute, - condition={"_type": "EmptyCheck"}, - detail="空判定はこの属性種類では利用できません。", - ) - assert isinstance(attribute_obj, EmptyCheckMixin) - return attribute_obj - - def _raise_invalid_ast(*, attribute: AttributeDefinition, ast: RestrictionAst) -> NoReturn: """ 属性型に対して不正なAST種別が指定されたことを表す例外を送出します。 From 814d57e9ba2cd524d52a224ec70e82a43209fa7a Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 03:14:19 +0900 Subject: [PATCH 42/47] Inline attribute choice access --- annofabapi/util/attribute_restrictions.py | 31 +++++------------------ 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index f3da861a..91485691 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -30,7 +30,7 @@ from abc import ABC, abstractmethod from collections.abc import Collection from enum import Enum -from typing import Any, NoReturn +from typing import Any, NoReturn, cast from pydantic import BaseModel, ConfigDict, Field, field_serializer, model_validator @@ -364,13 +364,13 @@ def _is_valid_attribute_type(self) -> bool: def has_choice(self, *, choice_id: str | None = None, choice_name: str | None = None) -> Restriction: """引数`choice_id`または`choice_name`に一致する選択肢が選択されているという制約""" - choices = _get_attribute_choices(self.attribute) + choices = cast(list[AttributeChoice], 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: str | None = None, choice_name: str | None = None) -> Restriction: """引数`choice_id`または`choice_name`に一致する選択肢が選択されていないという制約""" - choices = _get_attribute_choices(self.attribute) + choices = cast(list[AttributeChoice], self.attribute["choices"]) choice = get_choice(choices, choice_id=choice_id, choice_name=choice_name) return NotEquals(self.attribute_id, choice["choice_id"]) @@ -799,7 +799,7 @@ def get_allowed_ast_types(attribute_type: AdditionalDataDefinitionType) -> list[ label_names = None match attribute_type: case "choice" | "select": - choice_names = [get_english_message(choice["name"]) for choice in _get_attribute_choices(attribute)] + choice_names = [get_english_message(choice["name"]) for choice in cast(list[AttributeChoice], attribute["choices"])] case "link": label_names = [get_english_message(label["label_name"]) for label in accessor.labels] item = AttributeRestrictionCatalogItem( @@ -813,25 +813,6 @@ def get_allowed_ast_types(attribute_type: AdditionalDataDefinitionType) -> list[ return catalog -def _get_attribute_choices(attribute: AttributeDefinition) -> list[AttributeChoice]: - """ - 属性定義から選択肢一覧を取得します。 - - Args: - attribute: アノテーション仕様上の属性定義です。 - - Returns: - 属性に紐づく選択肢一覧です。 - - Raises: - ValueError: 選択肢を持たない属性に対して呼び出された場合 - """ - choices = attribute["choices"] - if choices is None: - raise ValueError(f"属性(type='{attribute['type']}')には選択肢がありません。") - return choices - - def _from_restriction_dict(obj: dict[str, Any]) -> Restriction: """ API向けの制約辞書から `Restriction` を復元します。 @@ -1117,7 +1098,7 @@ def equals_restriction_to_ast( value=_parse_integer_value(value, attribute=attribute, condition={"_type": "Equals", "value": value}), ) case "choice" | "select": - choice = get_choice(_get_attribute_choices(attribute), choice_id=value) + choice = get_choice(cast(list[AttributeChoice], attribute["choices"]), choice_id=value) return RestrictionAst( type=RestrictionAstType.HAS_CHOICE, attribute_name=attribute_name, @@ -1149,7 +1130,7 @@ def not_equals_restriction_to_ast( value=_parse_integer_value(value, attribute=attribute, condition={"_type": "NotEquals", "value": value}), ) case "choice" | "select": - choice = get_choice(_get_attribute_choices(attribute), choice_id=value) + choice = get_choice(cast(list[AttributeChoice], attribute["choices"]), choice_id=value) return RestrictionAst( type=RestrictionAstType.NOT_HAS_CHOICE, attribute_name=attribute_name, From c55afe246723bed52aa61606d5cf817cd41ce428 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 03:15:53 +0900 Subject: [PATCH 43/47] Internalize invalid AST helper --- annofabapi/util/attribute_restrictions.py | 33 +++++++++++------------ 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index 91485691..6e1d08c8 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -876,6 +876,20 @@ def _ast_to_restriction(ast: RestrictionAst, *, fac: AttributeFactory) -> Restri ValueError: AST種別が未知の場合、または属性型に対して利用できないASTが指定された場合 """ + def raise_invalid_ast(*, attribute: AttributeDefinition, ast: RestrictionAst) -> NoReturn: + """ + 属性型に対して不正なAST種別が指定されたことを表す例外を送出します。 + + Args: + attribute: アノテーション仕様上の属性定義です。 + ast: 不正だったASTです。 + + Raises: + ValueError: 常に送出されます。 + """ + attribute_name = get_english_message(attribute["name"]) + raise ValueError(f"属性'{attribute_name}'(type='{attribute['type']}')ではAST種別'{ast.type}'を利用できません。") + def attribute_with_empty_check(*, attribute: AttributeDefinition) -> EmptyCheckMixin: """ 空判定をサポートする属性オブジェクトを取得します。 @@ -913,7 +927,7 @@ def ast_string_equality_to_restriction( case "tracking": attribute_obj = fac.tracking_id(attribute_name=ast.attribute_name) case _: - _raise_invalid_ast(attribute=attribute, ast=ast) + raise_invalid_ast(attribute=attribute, ast=ast) match ast.type: case RestrictionAstType.EQUALS_STRING: @@ -931,7 +945,7 @@ def ast_string_match_to_restriction( ) -> Restriction: assert isinstance(ast.value, str) if attribute_type not in {"text", "comment"}: - _raise_invalid_ast(attribute=attribute, ast=ast) + raise_invalid_ast(attribute=attribute, ast=ast) attribute_obj = fac.string_textbox(attribute_name=ast.attribute_name) match ast.type: @@ -1006,21 +1020,6 @@ def ast_to_atomic_restriction(ast: RestrictionAst, *, attribute: AttributeDefini return ast_to_atomic_restriction(ast, attribute=attribute) -def _raise_invalid_ast(*, attribute: AttributeDefinition, ast: RestrictionAst) -> NoReturn: - """ - 属性型に対して不正なAST種別が指定されたことを表す例外を送出します。 - - Args: - attribute: アノテーション仕様上の属性定義です。 - ast: 不正だったASTです。 - - Raises: - ValueError: 常に送出されます。 - """ - attribute_name = get_english_message(attribute["name"]) - raise ValueError(f"属性'{attribute_name}'(type='{attribute['type']}')ではAST種別'{ast.type}'を利用できません。") - - def _parse_integer_value(value: str, *, attribute: AttributeDefinition, condition: dict[str, Any]) -> int: """ 整数属性向けの文字列値を整数へ変換します。 From cd3340f9480405b48b7980c7d9a774de6ad0a82d Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 03:16:40 +0900 Subject: [PATCH 44/47] Internalize integer parsing helper --- annofabapi/util/attribute_restrictions.py | 47 +++++++++++------------ 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index 6e1d08c8..ffec38ba 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -1020,28 +1020,6 @@ def ast_to_atomic_restriction(ast: RestrictionAst, *, attribute: AttributeDefini return ast_to_atomic_restriction(ast, attribute=attribute) -def _parse_integer_value(value: str, *, attribute: AttributeDefinition, condition: dict[str, Any]) -> int: - """ - 整数属性向けの文字列値を整数へ変換します。 - - Args: - value: 変換対象の文字列値です。 - attribute: アノテーション仕様上の属性定義です。 - condition: 元の条件辞書です。 - - Returns: - 変換後の整数値です。 - - Raises: - ValueError: 整数へ変換できない場合 - """ - try: - return int(value) - except ValueError as exc: - _raise_invalid_restriction(attribute=attribute, condition=condition, detail="整数属性には整数値を指定してください。") - raise AssertionError("unreachable") from exc - - def _raise_invalid_restriction(*, attribute: AttributeDefinition, condition: dict[str, Any], detail: str | None = None) -> NoReturn: """ 属性型に対して不正な制約が指定されたことを表す例外を送出します。 @@ -1076,6 +1054,27 @@ def _restriction_to_ast(restriction: Restriction, *, accessor: AnnotationSpecsAc ValueError: ASTへ変換できない制約が含まれている場合 """ + def parse_integer_value(value: str, *, attribute: AttributeDefinition, condition: dict[str, Any]) -> int: + """ + 整数属性向けの文字列値を整数へ変換します。 + + Args: + value: 変換対象の文字列値です。 + attribute: アノテーション仕様上の属性定義です。 + condition: 元の条件辞書です。 + + Returns: + 変換後の整数値です。 + + Raises: + ValueError: 整数へ変換できない場合 + """ + try: + return int(value) + except ValueError as exc: + _raise_invalid_restriction(attribute=attribute, condition=condition, detail="整数属性には整数値を指定してください。") + raise AssertionError("unreachable") from exc + def equals_restriction_to_ast( *, attribute: AttributeDefinition, @@ -1094,7 +1093,7 @@ def equals_restriction_to_ast( return RestrictionAst( type=RestrictionAstType.EQUALS_INTEGER, attribute_name=attribute_name, - value=_parse_integer_value(value, attribute=attribute, condition={"_type": "Equals", "value": value}), + value=parse_integer_value(value, attribute=attribute, condition={"_type": "Equals", "value": value}), ) case "choice" | "select": choice = get_choice(cast(list[AttributeChoice], attribute["choices"]), choice_id=value) @@ -1126,7 +1125,7 @@ def not_equals_restriction_to_ast( return RestrictionAst( type=RestrictionAstType.NOT_EQUALS_INTEGER, attribute_name=attribute_name, - value=_parse_integer_value(value, attribute=attribute, condition={"_type": "NotEquals", "value": value}), + value=parse_integer_value(value, attribute=attribute, condition={"_type": "NotEquals", "value": value}), ) case "choice" | "select": choice = get_choice(cast(list[AttributeChoice], attribute["choices"]), choice_id=value) From 0b4da1651e1d110b762ed787be82a145087f5da7 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 10:01:43 +0900 Subject: [PATCH 45/47] =?UTF-8?q?test:=20pytest.raises=E3=81=AEmatch?= =?UTF-8?q?=E6=8C=87=E5=AE=9A=E3=82=92=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/util/test_attribute_restrictions.py | 8 ++++---- tests/util/test_page.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/util/test_attribute_restrictions.py b/tests/util/test_attribute_restrictions.py index 2e642024..3d8e1cde 100644 --- a/tests/util/test_attribute_restrictions.py +++ b/tests/util/test_attribute_restrictions.py @@ -37,7 +37,7 @@ def test__unchecked(self): } def test__is_valid_attribute_type(self): - with pytest.raises(ValueError, match="属性の種類が'tracking'である属性は、クラス'Checkbox'では扱えません。"): + with pytest.raises(ValueError): Checkbox(accessor, attribute_id="d349e76d-b59a-44cd-94b4-713a00b2e84d") @@ -323,7 +323,7 @@ def test__to_ast__tracking_id属性にmatchesは指定できない(self): } restriction = Restriction.from_dict(restriction_dict) - with pytest.raises(ValueError, match="属性'tracking'\\(type='tracking'\\)では制約'Matches'を利用できません。"): + with pytest.raises(ValueError): restriction.to_ast(accessor.annotation_specs) def test__to_ast__integer属性に整数以外の値は指定できない(self): @@ -333,7 +333,7 @@ def test__to_ast__integer属性に整数以外の値は指定できない(self): } restriction = Restriction.from_dict(restriction_dict) - with pytest.raises(ValueError, match="整数属性には整数値を指定してください。"): + with pytest.raises(ValueError): restriction.to_ast(accessor.annotation_specs) def test__from_dict__can_input_true(self): @@ -415,7 +415,7 @@ def test__to_restriction(self): def test__to_restriction__trackingにはmatches_stringを指定できない(self): ast = RestrictionAst(type=RestrictionAstType.MATCHES_STRING, attribute_name="tracking", value="foo") - with pytest.raises(ValueError, match="属性'tracking'\\(type='tracking'\\)ではAST種別'matches_string'を利用できません。"): + with pytest.raises(ValueError): ast.to_restriction(accessor.annotation_specs) def test__to_human_readable(self): diff --git a/tests/util/test_page.py b/tests/util/test_page.py index daa7d3b8..fd18602d 100644 --- a/tests/util/test_page.py +++ b/tests/util/test_page.py @@ -58,7 +58,7 @@ def test_with_both_ids(self): def test_annotation_id_without_input_data_id_raises_error(self): """annotation_idのみを指定した場合にValueErrorが発生することをテスト""" - with pytest.raises(ValueError, match="'input_data_id' must be specified if 'annotation_id' is specified"): + with pytest.raises(ValueError): create_image_editor_url("project1", "task1", annotation_id="annotation1") @@ -92,5 +92,5 @@ def test_with_both_ids(self): def test_annotation_id_without_input_data_id_raises_error(self): """annotation_idのみを指定した場合にValueErrorが発生することをテスト""" - with pytest.raises(ValueError, match="'input_data_id' must be specified if 'annotation_id' is specified"): + with pytest.raises(ValueError): create_3dpc_editor_url("project1", "task1", annotation_id="annotation1") From 7ae070a3b4727f500c9cbea2a3215483cb33fab7 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 10:01:57 +0900 Subject: [PATCH 46/47] Remove unnecessary ValueError raise documentation from Restriction class --- annofabapi/util/attribute_restrictions.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index ffec38ba..398940d3 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -103,8 +103,6 @@ def from_dict(cls, obj: dict[str, Any]) -> "Restriction": Returns: 復元した `Restriction` オブジェクトです。 - Raises: - ValueError: 制約の形式が不正な場合 """ return _from_restriction_dict(obj) From 6328cdb5edbc04e8c176dd98e159c25e0ee7e724 Mon Sep 17 00:00:00 2001 From: yuji38kwmt Date: Wed, 29 Apr 2026 10:07:58 +0900 Subject: [PATCH 47/47] Optimize link label catalog generation --- annofabapi/util/attribute_restrictions.py | 5 ++++- tests/util/test_attribute_restrictions.py | 21 ++++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index 398940d3..e146a912 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -790,6 +790,7 @@ def get_allowed_ast_types(attribute_type: AdditionalDataDefinitionType) -> list[ raise ValueError(f"未対応の属性種類です。 :: attribute_type='{attribute_type}'") accessor = AnnotationSpecsAccessor(annotation_specs) + all_label_names: list[str] | None = None catalog: list[AttributeRestrictionCatalogItem] = [] for attribute in accessor.additionals: attribute_type = attribute["type"] @@ -799,7 +800,9 @@ def get_allowed_ast_types(attribute_type: AdditionalDataDefinitionType) -> list[ case "choice" | "select": choice_names = [get_english_message(choice["name"]) for choice in cast(list[AttributeChoice], attribute["choices"])] case "link": - label_names = [get_english_message(label["label_name"]) for label in accessor.labels] + if all_label_names is None: + all_label_names = [get_english_message(label["label_name"]) for label in accessor.labels] + label_names = all_label_names item = AttributeRestrictionCatalogItem( attribute_name=get_english_message(attribute["name"]), attribute_type=attribute_type, diff --git a/tests/util/test_attribute_restrictions.py b/tests/util/test_attribute_restrictions.py index 3d8e1cde..edfbd3fc 100644 --- a/tests/util/test_attribute_restrictions.py +++ b/tests/util/test_attribute_restrictions.py @@ -1,3 +1,4 @@ +import copy import json from pathlib import Path @@ -5,7 +6,7 @@ from pydantic import ValidationError from annofabapi.pydantic_models.additional_data_definition_type import AdditionalDataDefinitionType -from annofabapi.util.annotation_specs import AnnotationSpecsAccessor +from annofabapi.util.annotation_specs import AnnotationSpecsAccessor, get_english_message from annofabapi.util.attribute_restrictions import ( AnnotationLink, AttributeFactory, @@ -463,6 +464,24 @@ def test__catalog(self): "label_names": None, } in [item.model_dump() for item in actual] + def test__複数link属性でも同じラベル一覧を返す(self): + annotation_specs = copy.deepcopy(accessor.annotation_specs) + expected_label_names = [get_english_message(label["label_name"]) for label in annotation_specs["labels"]] + link_attribute = next(attribute for attribute in annotation_specs["additionals"] if attribute["type"] == "link") + copied_link_attribute = copy.deepcopy(link_attribute) + copied_link_attribute["additional_data_definition_id"] = "dummy-link-id" + copied_link_attribute["name"]["messages"][0]["message"] = "link_car_2" + copied_link_attribute["name"]["messages"][1]["message"] = "リンク_車両2" + copied_link_attribute["name"]["messages"][2]["message"] = "link_car_2" + annotation_specs["additionals"].append(copied_link_attribute) + + actual = get_attribute_restriction_catalog(annotation_specs) + + link_items = [item for item in actual if item.attribute_type == AdditionalDataDefinitionType.LINK] + assert len(link_items) == 2 + assert link_items[0].label_names == expected_label_names + assert link_items[1].label_names == expected_label_names + def test__catalog_model_json_schema(self): actual = AttributeRestrictionCatalogItem.model_json_schema()