diff --git a/README_for_developer.md b/README_for_developer.md index eba01352..9a3774dc 100644 --- a/README_for_developer.md +++ b/README_for_developer.md @@ -2,60 +2,28 @@ 開発者用のドキュメントです。 ソースコードの生成、テスト実行、リリース手順などを記載します。 -## Requirements +# Requirements * Bash * Docker (OpenAPI Generatorを実行するのに必要) * python 3.6+ - * poetry -## Install +# Install 以下のコマンドを実行してください。開発に必要な環境が構築されます。 ```bash $ make init ``` -## Source +# Test -### ソースコードの生成 -annofabapiのいくつかのファイルは、[AnnoFab Web APIのOpenAPI Spec](https://annofab.com/docs/api/swagger.yaml)から自動生成しています。 -以下のコマンドを実行すると、ソースコードが生成されます。詳細は[generate/README.md](generate/README.md)を参照してください。 - -``` -# `generate/swagger/*.yaml`ファイルから、ソースコードを生成する -$ generate/generate.sh - -# AnnoFab WebAPIのOpenAPI Spec を`generate/swagger/`にダウンロードしてから、ソースコードを生成する -$ generate/generate.sh --download - -``` - -### フォーマットを実行 -以下のコマンドを実行してください。 - -``` -$ make format -``` - -### lintを実行 -以下のコマンドを実行してください。 - -``` -$ make lint -``` - -## Test - -### テストの実行方法 +## テストの実行方法 1. AnnoFabの認証情報を、`.netrc`ファイルまたは環境変数に設定する。 -2. `pytest.ini`に、テスト対象の`project_id`と`task_id`を指定する。 +2. 以下のコマンドを実行して、テスト用のプロジェクトとタスクを作成する。 + * `poetry run python tests/create_test_project.py --organization ${MY_ORGANIZATION}` +3. `pytest.ini`に、テスト対象の`project_id`と`task_id`を指定する。 * `task_id`はプロジェクト`project_id`配下であること * **【注意】テストを実行すると、AnnoFabプロジェクトの内容が変更される** -3. `$ make test`コマンドを実行する。 - -#### タスクの前提条件 -* タスクの先頭画像にアノテーションが1個以上付与されている -* タスクの先頭画像に検査コメントが1個以上付与されている +4. `$ make test`コマンドを実行する。 #### テストメソッドを指定してテストする方法 @@ -89,8 +57,17 @@ $ poetry run pytest --print_log_annofabapi tests * 「パスワード変更」など使用頻度が少なく、実行や確認がしづらいメソッド +# Versioning +annofabapiのバージョンはSemantic Versioning 2.0に従います。 +* メソッドが追加されたときは、マイナーバージョンを上げる。 +* annofabapiのバグ/ドキュメント修正などにより、annofabapiをリリースするときは、パッチバージョンを上げる。 + +annofabapiのバージョンは以下のファイルで定義しています。 +* `annofabapi/__version__.py` +* `pyproject.toml` -## Release + +# PyPIへのリリース方法 ## 事前作業 @@ -102,30 +79,12 @@ https://pypi.org/account/register/ https://pypi.org/project/annofabapi/ ## リリース方法 - -### 1. annofabapiのバージョンを上げる -以下のファイルに記載されているバージョンを上げてください。 -* `annofabapi/__version__.py` -* `pyproject.toml` - -バージョンはSemantic Versioning 2.0に従います。 -* メソッドが追加されたときは、マイナーバージョンを上げる。 -* annofabapiのバグ/ドキュメント修正などにより、annofabapiをリリースするときは、パッチバージョンを上げる。 - - -### 2. PyPIに登録する +以下のコマンドを実行してください。PyPIのユーザ名とパスワードの入力が求められます。 ``` $ make publish ``` -※ PyPIのユーザ名とパスワードの入力が求められます。 - - - -### 3. GitHubのリリースページに追加 -GitHubのRelease機能を使って、リリース情報を記載します。 - ## Document ### ドキュメントの作成 @@ -143,3 +102,44 @@ ReadTheDocsのビルド結果は https://readthedocs.org/projects/annofab-api-py ## 開発フロー * masterブランチを元にしてブランチを作成して、プルリクを作成してください。masterブランチへの直接pushすることはGitHub上で禁止しています。 * リリース時のソースはGitHubのRelease機能、またはPyPIからダウンロードしてください。 + + + + +----------------- +# AnnoFab WebAPIの更新により、リリースする +### 1.ソースコードの生成 + +annofabapiのいくつかのファイルは、[AnnoFab Web APIのOpenAPI Spec](https://annofab.com/docs/api/swagger.yaml)から自動生成しています。 +以下のコマンドを実行すると、ソースコードが生成されます。詳細は[generate/README.md](generate/README.md)を参照してください。 + +``` +# `generate/swagger/*.yaml`ファイルから、ソースコードを生成する +$ generate/generate.sh + +# AnnoFab WebAPIのOpenAPI Spec を`generate/swagger/`にダウンロードしてから、ソースコードを生成する +$ generate/generate.sh --download + +$ make format && make lint +``` + +### 2.テストの実施 +「テストの実行方法」を参照 + +### 3.versionを上げる +「Versioning」を参照 + +### 4.プルリクを作ってマージする + +### 5.PyPIへパッケージをアップロードする +「PyPIへのリリース方法」を参照 + +### 6.GitHubのリリースページに追加 +GitHubのRelease機能を使って、リリース情報を記載します。 + + + + + + + diff --git a/annofabapi/__version__.py b/annofabapi/__version__.py index 01b570aa..47a53c14 100644 --- a/annofabapi/__version__.py +++ b/annofabapi/__version__.py @@ -1 +1 @@ -__version__ = "0.40.1" +__version__ = "0.40.2" diff --git a/annofabapi/wrapper.py b/annofabapi/wrapper.py index 55eb1c08..0486da11 100644 --- a/annofabapi/wrapper.py +++ b/annofabapi/wrapper.py @@ -39,6 +39,7 @@ SimpleAnnotationDetail, SupplementaryData, Task, + TaskStatus, ) from annofabapi.parser import SimpleAnnotationDirParser, SimpleAnnotationParser from annofabapi.utils import _download, _log_error_response, _raise_for_status, allow_404_error, str_now @@ -1448,6 +1449,222 @@ def get_all_tasks(self, project_id: str, query_params: Optional[Dict[str, Any]] """ return self._get_all_objects(self.api.get_tasks, limit=200, project_id=project_id, query_params=query_params) + def change_task_status_to_working(self, project_id: str, task_id: str) -> Task: + """ + タスクのステータスを「作業中」に変更します。 + + Notes: + * 現在タスクを担当しているユーザーのみ、この操作を行うことができます。 + * 現在の状態が未着手(not_started)、休憩中(break)、保留(on_hold)のいずれかであるタスクに対してのみ、この操作を行うことができます。 + + Args: + project_id: プロジェクトID + task_id: タスクID + + Returns: + 変更後のタスク + """ + task, _ = self.api.get_task(project_id, task_id) + request_body = { + "status": TaskStatus.WORKING.value, + "account_id": self.api.account_id, + "last_updated_datetime": task["updated_datetime"], + } + updated_task, _ = self.api.operate_task(project_id, task_id, request_body=request_body) + return updated_task + + def change_task_status_to_break(self, project_id: str, task_id: str) -> Task: + """ + タスクのステータスを「休憩中」に変更します。 + + Notes: + * 現在タスクを担当しているユーザーのみ、この操作を行うことができます。 + * 現在の状態が作業中(working)のタスクに対してのみ、この操作を行うことができます。 + + Args: + project_id: プロジェクトID + task_id: タスクID + + Returns: + 変更後のタスク + """ + task, _ = self.api.get_task(project_id, task_id) + request_body = { + "status": TaskStatus.BREAK.value, + "account_id": self.api.account_id, + "last_updated_datetime": task["updated_datetime"], + } + updated_task, _ = self.api.operate_task(project_id, task_id, request_body=request_body) + return updated_task + + def change_task_status_to_on_hold(self, project_id: str, task_id: str) -> Task: + """ + タスクのステータスを「保留」に変更します。 + + Notes: + * 現在タスクを担当しているユーザーのみ、この操作を行うことができます。 + * 現在の状態が作業中(working)のタスクに対してのみ、この操作を行うことができます。 + + Args: + project_id: プロジェクトID + task_id: タスクID + + Returns: + 変更後のタスク + """ + task, _ = self.api.get_task(project_id, task_id) + request_body = { + "status": TaskStatus.ON_HOLD.value, + "account_id": self.api.account_id, + "last_updated_datetime": task["updated_datetime"], + } + updated_task, _ = self.api.operate_task(project_id, task_id, request_body=request_body) + return updated_task + + def complete_task(self, project_id: str, task_id: str) -> Task: + """ + 今のフェーズを完了させ、 次のフェーズに遷移させます。 + 教師付フェーズのときはタスクを提出します。 + 検査/受入フェーズのときは、タスクを合格にします。 + + + Notes: + * 現在タスクを担当しているユーザーのみ、この操作を行うことができます。 + * 現在の状態が作業中(working)のタスクに対してのみ、この操作を行うことができます。 + + Args: + project_id: プロジェクトID + task_id: タスクID + + Returns: + 変更後のタスク + """ + task, _ = self.api.get_task(project_id, task_id) + request_body = { + "status": TaskStatus.COMPLETE.value, + "account_id": self.api.account_id, + "last_updated_datetime": task["updated_datetime"], + } + updated_task, _ = self.api.operate_task(project_id, task_id, request_body=request_body) + return updated_task + + def cancel_submitted_task(self, project_id: str, task_id: str) -> Task: + """ + タスクの提出を取り消します。 + 「提出されたタスク」とは以下の状態になっています。 + * 教師付フェーズで「提出」ボタンを押して、検査/受入フェーズへ遷移したタスク + * 検査フェーズから「合格」ボタンを押して、受入フェーズへ遷移したタスク + + Notes: + * 現在タスクを担当しているユーザーのみ、この操作を行うことができます。 + * タスク提出後に検査/受入(抜取含む)等の作業が一切行われていない場合のみ、この操作を行うことができます。 + * 現在の状態が未着手(not_started)のタスクに対してのみ、この操作を行うことができます。 + * 現在のフェーズが検査(inspection)、もしくは受入(acceptance)のタスクに対してのみ、この操作を行うことができます。 + + Args: + project_id: プロジェクトID + task_id: タスクID + + Returns: + 変更後のタスク + """ + task, _ = self.api.get_task(project_id, task_id) + request_body = { + "status": TaskStatus.CANCELLED.value, + "account_id": self.api.account_id, + "last_updated_datetime": task["updated_datetime"], + } + updated_task, _ = self.api.operate_task(project_id, task_id, request_body=request_body) + return updated_task + + def cancel_completed_task(self, project_id: str, task_id: str, operator_account_id: Optional[str] = None) -> Task: + """ + タスクの受入完了状態を取り消す。 + + Args: + project_id: プロジェクトID + task_id: タスクID + operator_account_id: 受入完了状態を取り消した後の担当者のaccount_id + + Returns: + 変更後のタスク + """ + + task, _ = self.api.get_task(project_id, task_id) + + request_body = { + "status": TaskStatus.NOT_STARTED.value, + "account_id": operator_account_id, + "last_updated_datetime": task["updated_datetime"], + } + updated_task, _ = self.api.operate_task(project_id, task_id, request_body=request_body) + return updated_task + + def change_task_operator( + self, project_id: str, task_id: str, operator_account_id: Optional[str] = None + ) -> Dict[str, Any]: + """ + タスクの担当者を変更します。 + + Notes: + * プロジェクトオーナー(owner)、もしくは受入担当者(accepter)のみ、この操作を行うことができます。 + + Args: + project_id: プロジェクトID + task_id: タスクID + operator_account_id: 新しい担当者のaccount_id。Noneの場合は、担当者を「未割り当て」にします。 + + Returns: + 変更後のタスク + + """ + task, _ = self.api.get_task(project_id, task_id) + + request_body = { + "status": TaskStatus.NOT_STARTED.value, + "account_id": operator_account_id, + "last_updated_datetime": task["updated_datetime"], + } + updated_task, _ = self.api.operate_task(project_id, task_id, request_body=request_body) + return updated_task + + def reject_task(self, project_id: str, task_id: str, force: bool = False) -> Dict[str, Any]: + """ + タスクを差し戻します。 + * 通常の差し戻しの場合、タスクの担当者は未割り当てになります。 + * 強制差し戻しの場合、タスクの担当者は直前の教師付フェーズの担当者になります。 + + Notes: + * 通常の差し戻しの場合 + * 現在タスクを担当しているユーザーのみ、この操作を行うことができます。 + * 現在の状態が作業中(working)のタスクに対してのみ、この操作を行うことができます。 + * 現在のフェーズが検査(inspection)、もしくは受入(acceptance)のタスクに対してのみ、この操作を行うことができます。 + * 強制差し戻しの場合 + * タスクの状態・フェーズを無視して、フェーズを教師付け(annotation)に、状態を未作業(not started)に変更します。 + * タスクの担当者としては、直前の教師付け(annotation)フェーズの担当者を割り当てます。 + * この差戻しは、抜取検査・抜取受入のスキップ判定に影響を及ぼしません。 + + Args: + project_id: プロジェクトID + task_id: タスクID + force: Trueなら強制差し戻し、Falseなら通常の差し戻しを実施する + + Returns: + 変更後のタスク + + """ + + task, _ = self.api.get_task(project_id, task_id) + + request_body = { + "status": TaskStatus.REJECTED.value, + "account_id": self.api.account_id, + "last_updated_datetime": task["updated_datetime"], + "force": force, + } + updated_task, _ = self.api.operate_task(project_id, task_id, request_body=request_body) + return updated_task + ######################################### # Public Method : Instruction ######################################### diff --git a/pyproject.toml b/pyproject.toml index 2933a050..95a70445 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "annofabapi" -version = "0.40.1" +version = "0.40.2" description = "Python Clinet Library of AnnoFab WebAPI (https://annofab.com/docs/api/)" authors = ["yuji38kwmt"] license = "MIT" diff --git a/pytest.ini b/pytest.ini index a66d8820..3555975c 100644 --- a/pytest.ini +++ b/pytest.ini @@ -5,10 +5,11 @@ addopts = --verbose --capture=no -rs [annofab] -;endpoint_url = "https://annofab.com" +endpoint_url = https://annofab.com # Caution : Annofab project may be changed!! # Specify AnnoFab project that has owner role assigned to you. -project_id = 58a2a621-7d4b-41e7-927b-cdc570c1114a +project_id = 1ae6ec18-2a71-4eb5-9ac1-92329b01a5ca +task_id = test_task_1 + -task_id = sample_423 diff --git a/tests/create_test_project.py b/tests/create_test_project.py new file mode 100644 index 00000000..d1bd19f9 --- /dev/null +++ b/tests/create_test_project.py @@ -0,0 +1,275 @@ +import argparse +import logging +import os +import uuid +from argparse import ArgumentParser +from typing import Any, Dict, List, Optional + +import annofabapi +from annofabapi.models import TaskPhase + +logger = logging.getLogger(__name__) + + +class CreatingTestProject: + def __init__(self, service: annofabapi.Resource): + self.service = service + + self.labels_dict = {"car": "car_label_id"} + + def create_project(self, organization_name: str, project_title: Optional[str] = None) -> Dict[str, Any]: + project_id = str(uuid.uuid4()) + DEFAULT_PROJECT_TITLE = "annofabapiのテスト用プロジェクト(自動生成)" + + request_body = { + "title": project_title if project_title is not None else DEFAULT_PROJECT_TITLE, + "status": "active", + "organization_name": organization_name, + "configuration": {}, + } + new_project, _ = self.service.api.put_project(project_id, request_body=request_body) + return new_project + + def _create_bbox_label(self) -> Dict[str, Any]: + label_name = "car" + label_id = self.labels_dict[label_name] + return { + "label_id": label_id, + "label_name": { + "messages": [{"lang": "ja-JP", "message": label_name}, {"lang": "en-US", "message": label_name}], + "default_lang": "ja-JP", + }, + "annotation_type": "bounding_box", + "keybind": [], + "additional_data_definitions": [], + "color": {"red": 255, "green": 0, "blue": 0}, + "annotation_editor_feature": { + "append": False, + "erase": False, + "freehand": False, + "rectangle_fill": False, + "polygon_fill": False, + "fill_near": False, + }, + } + + def create_annotation_specs(self, project_id: str) -> Dict[str, Any]: + old_annotation_specs, _ = self.service.api.get_annotation_specs(project_id) + request_body = { + "labels": [self._create_bbox_label()], + "last_updated_datetime": old_annotation_specs["updated_datetime"], + } + annotation_specs, _ = self.service.api.put_annotation_specs(project_id, request_body=request_body) + return annotation_specs + + def create_input_data(self, project_id: str, input_data_id: str, image_path: str): + """ + サンプルの入力データを登録する。 + """ + old_input_data = self.service.wrapper.get_input_data_or_none(project_id, input_data_id) + if old_input_data is not None: + logger.debug(f"入力データをすでに存在していたので、登録しません。input_data_id={input_data_id}") + return + + request_body = { + "input_data_name": "AnnoFab Logo Image", + "input_data_path": "https://annofab.com/images/logo.png", + } + self.service.wrapper.put_input_data_from_file( + project_id, input_data_id=input_data_id, file_path=image_path, request_body=request_body + ) + logger.debug(f"入力データを登録しました。input_data_id={input_data_id}") + return + + def create_task(self, project_id: str, task_id: str, input_data_id_list: List[str]): + """ + サンプルのタスクを登録する。 + """ + old_task = self.service.wrapper.get_task_or_none(project_id, task_id) + if old_task is not None: + logger.debug(f"タスクはすでに存在していたので、登録しません。task_id={task_id}") + return + + request_body = { + "input_data_id_list": input_data_id_list, + } + self.service.api.put_task(project_id, task_id=task_id, request_body=request_body) + logger.debug(f"タスクを登録しました。task_id={task_id}") + return + + def upload_instruction(self, project_id: str): + histories, _ = self.service.api.get_instruction_history(project_id) + if len(histories) > 0: + logger.debug("作業ガイドはすでに登録されているので、登録しません。") + return + + image_url = self.service.wrapper.upload_instruction_image( + project_id, image_id=str(uuid.uuid4()), file_path="tests/data/lenna.png" + ) + html_data = f"Test Instruction " + last_updated_datetime = histories[0]["updated_datetime"] if len(histories) > 0 else None + put_request_body = {"html": html_data, "last_updated_datetime": last_updated_datetime} + self.service.api.put_instruction(project_id, request_body=put_request_body) + logger.debug("作業ガイドを登録しました。") + + def create_annotations(self, project_id: str, task_id: str, input_data_id: str): + old_annotation, _ = self.service.api.get_editor_annotation(project_id, task_id, input_data_id) + if len(old_annotation["details"]) > 0: + logger.debug(f"task_id={task_id}, input_data_id={input_data_id}にすでにアノテーションは存在するので、アノテーションは登録しません。") + return + + request_body = { + "project_id": project_id, + "task_id": task_id, + "input_data_id": input_data_id, + "details": [ + { + "annotation_id": str(uuid.uuid4()), + "account_id": self.service.api.account_id, + "label_id": self.labels_dict["car"], + "is_protected": False, + "data_holding_type": "inner", + "additional_data_list": [], + "data": {"left_top": {"x": 0, "y": 0}, "right_bottom": {"x": 10, "y": 10}, "_type": "BoundingBox"}, + "etag": None, + "url": None, + "path": None, + "created_datetime": None, + "updated_datetime": None, + } + ], + "updated_datetime": None, + } + self.service.api.put_annotation(project_id, task_id, input_data_id, request_body=request_body) + logger.debug(f"アノテーションを作成しました。task_id={task_id}, input_data_id={input_data_id}") + + return + + def add_inspection_comment( + self, + project_id: str, + task: Dict[str, Any], + input_data_id: str, + inspection_comment: str, + ): + """ + 検査コメントを付与する。 + 先頭画像の左上に付与する。 + """ + inspection_data = {"x": 0, "y": 0, "_type": "Point"} + + req_inspection = [ + { + "data": { + "project_id": project_id, + "comment": inspection_comment, + "task_id": task["task_id"], + "input_data_id": input_data_id, + "inspection_id": str(uuid.uuid4()), + "phase": task["phase"], + "commenter_account_id": self.service.api.account_id, + "data": inspection_data, + "status": "annotator_action_required", + "created_datetime": task["updated_datetime"], + }, + "_type": "Put", + } + ] + + return self.service.api.batch_update_inspections( + project_id, task["task_id"], input_data_id, request_body=req_inspection + )[0] + + def create_inspection_comment(self, project_id: str, task_id: str, input_data_id: str): + """ + 検査コメントを付与する。 + """ + + old_inspections, _ = self.service.api.get_inspections(project_id, task_id, input_data_id) + if len(old_inspections) > 0: + logger.debug(f"task_id={task_id}, input_data_id={input_data_id}にすでに検査コメントは存在するので、検査コメントは登録しません。") + return + + # 自分自身を担当者にする + + task, _ = self.service.api.get_task(project_id, task_id) + if task["phase"] != TaskPhase.ACCEPTANCE.value: + # 受け入れフェーズに移行する + self.service.wrapper.change_task_operator( + project_id, task_id, operator_account_id=self.service.api.account_id + ) + self.service.wrapper.change_task_status_to_working(project_id, task_id) + self.service.wrapper.complete_task(project_id, task_id) + + # 検査コメントの付与 + self.service.wrapper.change_task_operator(project_id, task_id, operator_account_id=self.service.api.account_id) + self.service.wrapper.change_task_status_to_working(project_id, task_id) + task, _ = self.service.api.get_task(project_id, task_id) + self.add_inspection_comment(project_id, task, input_data_id=input_data_id, inspection_comment="テストコメント(自動生成)") + logger.debug(f"検査コメントを作成しました。task_id={task_id}, input_data_id={input_data_id}") + self.service.wrapper.change_task_status_to_break(project_id, task_id) + + def main( + self, organization_name: Optional[str], project_id: Optional[str], project_title: Optional[str] = None + ) -> None: + if project_id is None: + if organization_name is not None: + project = self.create_project(organization_name=organization_name, project_title=project_title) + project_id = project["project_id"] + logger.debug(f"project_id={project_id} プロジェクトを作成しました。") + else: + raise RuntimeError(f"organization_name がNoneなので、プロジェクトを作成できません") + + annotation_specs = self.create_annotation_specs(project_id) + logger.debug(f"アノテーション仕様を作成しました。") + + # プロジェクトトップに移動する + now_dir = os.getcwd() + os.chdir(os.path.dirname(os.path.abspath(__file__)) + "/../") + + input_data_id = "test_input_1" + self.create_input_data(project_id, input_data_id, image_path="tests/data/lenna.png") + + task_id = "test_task_1" + self.create_task(project_id, task_id, input_data_id_list=[input_data_id]) + + self.create_annotations(project_id, task_id, input_data_id) + self.create_inspection_comment(project_id, task_id, input_data_id) + + self.upload_instruction(project_id) + logger.debug(f"作業ガイドを登録しました。") + + # 移動前のディレクトリに戻る + os.chdir(now_dir) + + +def parse_args(): + parser = ArgumentParser( + description="annofabapiのテスト用プロジェクトを生成します。", formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + + project_group = parser.add_mutually_exclusive_group(required=True) + project_group.add_argument("-p", "--project_id", type=str, help="テスト用プロジェクトのproject_id。指定しない場合はプロジェクトを作成します。") + project_group.add_argument("-org", "--organization", type=str, help="プロジェクトを作成する対象の組織を指定してください。") + + return parser.parse_args() + + +def set_logging(): + logging_formatter = "%(levelname)-8s : %(asctime)s : %(filename)s : %(name)s : %(funcName)s : %(message)s" + logging.basicConfig(format=logging_formatter) + logging.getLogger("annofabapi").setLevel(level=logging.DEBUG) + logging.getLogger("__main__").setLevel(level=logging.DEBUG) + + +def main() -> None: + set_logging() + + args = parse_args() + + main_obj = CreatingTestProject(annofabapi.build()) + main_obj.main(organization_name=args.organization, project_id=args.project_id) + + +if __name__ == "__main__": + main()