annofabcli 1.104.1__py3-none-any.whl → 1.105.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -105,6 +105,11 @@ def _get_additional_data_v2(additional_data: dict[str, Any], attribute_value: At
105
105
  return get_english_message(additional_data["name"])
106
106
 
107
107
  additional_data_definition_id = additional_data["additional_data_definition_id"]
108
+ if attribute_value is None:
109
+ return {
110
+ "definition_id": additional_data_definition_id,
111
+ "value": None,
112
+ }
108
113
 
109
114
  additional_data_type: str = additional_data["type"]
110
115
 
@@ -0,0 +1,296 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import logging
6
+ import sys
7
+ from collections import defaultdict
8
+ from pathlib import Path
9
+ from typing import Any, Optional, Union
10
+
11
+ import annofabapi
12
+ import pandas
13
+ from annofabapi.models import ProjectMemberRole
14
+ from annofabapi.pydantic_models.task_status import TaskStatus
15
+ from pydantic import BaseModel
16
+
17
+ import annofabcli
18
+ from annofabcli.annotation.annotation_query import convert_attributes_from_cli_to_additional_data_list_v2
19
+ from annofabcli.annotation.dump_annotation import DumpAnnotationMain
20
+ from annofabcli.common.cli import (
21
+ COMMAND_LINE_ERROR_STATUS_CODE,
22
+ ArgumentParser,
23
+ CommandLine,
24
+ CommandLineWithConfirm,
25
+ build_annofabapi_resource_and_login,
26
+ get_json_from_args,
27
+ )
28
+ from annofabcli.common.facade import AnnofabApiFacade
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ Attributes = dict[str, Optional[Union[str, int, bool]]]
34
+ """属性情報"""
35
+
36
+
37
+ class TargetAnnotation(BaseModel):
38
+ task_id: str
39
+ input_data_id: str
40
+ annotation_id: str
41
+ attributes: Attributes
42
+
43
+
44
+ def get_annotation_list_per_task_id_input_data_id(anno_list: list[TargetAnnotation]) -> dict[str, dict[str, list[TargetAnnotation]]]:
45
+ """
46
+ タスクIDと入力データIDごとにアノテーションをグループ化する。
47
+
48
+ Args:
49
+ anno_list: アノテーションのリスト
50
+
51
+ Returns:
52
+ タスクIDと入力データIDをキーとしたアノテーションの辞書
53
+ """
54
+ grouped: dict[str, dict[str, list[TargetAnnotation]]] = defaultdict(lambda: defaultdict(list))
55
+
56
+ for annotation in anno_list:
57
+ grouped[annotation.task_id][annotation.input_data_id].append(annotation)
58
+ return grouped
59
+
60
+
61
+ class ChangeAnnotationAttributesPerAnnotationMain(CommandLineWithConfirm):
62
+ def __init__(
63
+ self,
64
+ service: annofabapi.Resource,
65
+ *,
66
+ project_id: str,
67
+ is_force: bool,
68
+ all_yes: bool,
69
+ backup_dir: Optional[Path] = None,
70
+ ) -> None:
71
+ self.service = service
72
+ self.project_id = project_id
73
+ self.is_force = is_force
74
+ self.backup_dir = backup_dir
75
+ self.annotation_specs, _ = self.service.api.get_annotation_specs(project_id, query_params={"v": "3"})
76
+ self.dump_annotation_obj = DumpAnnotationMain(service, project_id)
77
+ super().__init__(all_yes)
78
+
79
+ def change_annotation_attributes_by_frame(self, task_id: str, input_data_id: str, anno_list: list[TargetAnnotation]) -> bool:
80
+ """
81
+ フレームごとにアノテーション属性値を変更する。
82
+
83
+ Args:
84
+ task_id: タスクID
85
+ input_data_id: 入力データID
86
+ additional_data_list: 変更後の属性値(`AdditionalDataListV2`スキーマ)
87
+
88
+ """
89
+ editor_annotation, _ = self.service.api.get_editor_annotation(self.project_id, task_id=task_id, input_data_id=input_data_id, query_params={"v": "2"})
90
+
91
+ if self.backup_dir is not None:
92
+ (self.backup_dir / task_id).mkdir(exist_ok=True, parents=True)
93
+ self.dump_annotation_obj.dump_editor_annotation(editor_annotation, json_path=self.backup_dir / task_id / f"{input_data_id}.json")
94
+
95
+ details_map = {detail["annotation_id"]: detail for detail in editor_annotation["details"]}
96
+
97
+ def _to_request_body_elm(anno: TargetAnnotation) -> dict[str, Any]:
98
+ additional_data_list = convert_attributes_from_cli_to_additional_data_list_v2(anno.attributes, annotation_specs=self.annotation_specs)
99
+ return {
100
+ "data": {
101
+ "project_id": editor_annotation["project_id"],
102
+ "task_id": editor_annotation["task_id"],
103
+ "input_data_id": editor_annotation["input_data_id"],
104
+ "updated_datetime": editor_annotation["updated_datetime"],
105
+ "annotation_id": anno.annotation_id,
106
+ "label_id": details_map[anno.annotation_id]["label_id"],
107
+ "additional_data_list": additional_data_list,
108
+ },
109
+ "_type": "PutV2",
110
+ }
111
+
112
+ request_body = [_to_request_body_elm(annotation) for annotation in anno_list]
113
+
114
+ self.service.api.batch_update_annotations(self.project_id, request_body=request_body)
115
+ logger.debug(f"task_id='{task_id}', input_data_id='{input_data_id}' :: {len(request_body)}件の属性値を変更しました。")
116
+ return True
117
+
118
+ def change_annotation_attributes_for_task(self, task_id: str, annotation_list_per_input_data_id: dict[str, list[TargetAnnotation]]) -> tuple[bool, int, int]:
119
+ """
120
+ 1個のタスクに含まれるアノテーションの属性値を変更する。
121
+
122
+ Args:
123
+ task_id: タスクID
124
+ annotation_list_per_input_data_id: 入力データIDごとのアノテーションリスト
125
+
126
+ Returns:
127
+ tuple:
128
+ [0]: 属性値の変更可能なタスクかどうか
129
+ [1]: 属性値の変更に成功したアノテーション数
130
+ [2]: 属性値を変更できなかったアノテーション数
131
+
132
+ """
133
+ annotation_count = sum(len(v) for v in annotation_list_per_input_data_id.values())
134
+ task = self.service.wrapper.get_task_or_none(self.project_id, task_id)
135
+
136
+ succeed_to_change_annotation_count = 0
137
+ failed_to_change_annotation_count = 0
138
+
139
+ if task is None:
140
+ logger.warning(f"task_id='{task_id}' :: タスクが存在しないため、{annotation_count} 件のアノテーションの属性値の変更をスキップします。")
141
+ return False, 0, annotation_count
142
+
143
+ if task["status"] == TaskStatus.WORKING.value:
144
+ logger.info(f"task_id='{task_id}' :: タスクが作業中状態のため、{annotation_count} 件のアノテーションの属性値の変更をスキップします。")
145
+ failed_to_change_annotation_count += annotation_count
146
+ return False, 0, annotation_count
147
+
148
+ if not self.is_force: # noqa: SIM102
149
+ if task["status"] == TaskStatus.COMPLETE.value:
150
+ logger.info(
151
+ f"task_id='{task_id}' :: タスクが完了状態のため、アノテーション {annotation_count} 件のアノテーションの属性値の変更をスキップします。"
152
+ f"完了状態のタスクのアノテーションを削除するには、`--force`オプションを指定してください。"
153
+ )
154
+ failed_to_change_annotation_count += annotation_count
155
+ return False, 0, annotation_count
156
+
157
+ if not self.confirm_processing(f"task_id='{task_id}'に含まれるアノテーション{annotation_count}件の属性値を変更しますか?"):
158
+ return False, 0, annotation_count
159
+
160
+ for input_data_id, sub_anno_list in annotation_list_per_input_data_id.items():
161
+ try:
162
+ if self.change_annotation_attributes_by_frame(task_id, input_data_id, sub_anno_list):
163
+ succeed_to_change_annotation_count += len(sub_anno_list)
164
+ else:
165
+ failed_to_change_annotation_count += len(sub_anno_list)
166
+ except Exception:
167
+ logger.warning(f"task_id='{task_id}', input_data_id='{input_data_id}' :: アノテーションの属性値変更に失敗しました。", exc_info=True)
168
+ failed_to_change_annotation_count += len(sub_anno_list)
169
+ continue
170
+
171
+ return True, succeed_to_change_annotation_count, failed_to_change_annotation_count
172
+
173
+ def change_annotation_attributes(self, anno_list: list[TargetAnnotation]) -> None:
174
+ """
175
+ アノテーションごとに属性値を変更する。
176
+
177
+ Args:
178
+ anno_list: 各アノテーションの変更内容リスト
179
+ """
180
+ changed_task_count = 0
181
+ failed_to_change_annotation_count = 0
182
+ annotation_list_per_task_id_input_data_id = get_annotation_list_per_task_id_input_data_id(anno_list)
183
+
184
+ total_task_count = len(annotation_list_per_task_id_input_data_id)
185
+ total_annotation_count = len(anno_list)
186
+
187
+ for task_id, input_data_dict in annotation_list_per_task_id_input_data_id.items():
188
+ is_changeable_task, succeed_to_change_annotation_count, failed_to_change_annotation_count = self.change_annotation_attributes_for_task(task_id, input_data_dict)
189
+ if is_changeable_task:
190
+ changed_task_count += 1
191
+
192
+ logger.info(
193
+ f"{succeed_to_change_annotation_count}/{total_annotation_count} 件のアノテーションの属性値を変更しました。 :: "
194
+ f"アノテーションが変更されたタスク数は {changed_task_count}/{total_task_count} 件です。"
195
+ f"{failed_to_change_annotation_count} 件のアノテーションは変更できませんでした。"
196
+ )
197
+
198
+
199
+ class ChangeAttributesPerAnnotation(CommandLine):
200
+ """
201
+ アノテーションごとに属性値を個別変更
202
+ """
203
+
204
+ COMMON_MESSAGE = "annofabcli annotation change_attributes_per_annotation: error:"
205
+
206
+ def main(self) -> None:
207
+ args = self.args
208
+
209
+ if args.json is not None:
210
+ annotation_items = get_json_from_args(args.json)
211
+ if not isinstance(annotation_items, list):
212
+ print(f"{self.COMMON_MESSAGE} argument --json: JSON形式が不正です。オブジェクトの配列を指定してください。", file=sys.stderr) # noqa: T201
213
+ sys.exit(COMMAND_LINE_ERROR_STATUS_CODE)
214
+
215
+ target_annotation_list = [TargetAnnotation.model_validate(anno) for anno in annotation_items]
216
+
217
+ elif args.csv is not None:
218
+ df_input = pandas.read_csv(args.csv)
219
+ target_annotation_list = [
220
+ TargetAnnotation(task_id=e["task_id"], input_data_id=e["input_data_id"], annotation_id=e["annotation_id"], attributes=json.loads(e["attributes"]))
221
+ for e in df_input.to_dict(orient="records")
222
+ ]
223
+ else:
224
+ print(f"{self.COMMON_MESSAGE} argument '--json' または '--csv' のいずれかを指定してください。", file=sys.stderr) # noqa: T201
225
+ sys.exit(COMMAND_LINE_ERROR_STATUS_CODE)
226
+
227
+ project_id = args.project_id
228
+
229
+ if args.backup is None:
230
+ print( # noqa: T201
231
+ "間違えてアノテーションを変更してしまっときに復元できるようにするため、'--backup'でバックアップ用のディレクトリを指定することを推奨します。",
232
+ file=sys.stderr,
233
+ )
234
+ if not self.confirm_processing("復元用のバックアップディレクトリが指定されていません。処理を続行しますか?"):
235
+ sys.exit(COMMAND_LINE_ERROR_STATUS_CODE)
236
+ backup_dir = None
237
+ else:
238
+ backup_dir = Path(args.backup)
239
+
240
+ # プロジェクト権限チェック
241
+ super().validate_project(project_id, [ProjectMemberRole.OWNER, ProjectMemberRole.ACCEPTER])
242
+
243
+ main_obj = ChangeAnnotationAttributesPerAnnotationMain(self.service, project_id=project_id, all_yes=args.yes, is_force=args.force, backup_dir=backup_dir)
244
+ main_obj.change_annotation_attributes(target_annotation_list)
245
+
246
+
247
+ def main(args: argparse.Namespace) -> None:
248
+ service = build_annofabapi_resource_and_login(args)
249
+ facade = AnnofabApiFacade(service)
250
+ ChangeAttributesPerAnnotation(service, facade, args).main()
251
+
252
+
253
+ def parse_args(parser: argparse.ArgumentParser) -> None:
254
+ argument_parser = ArgumentParser(parser)
255
+ argument_parser.add_project_id()
256
+
257
+ sample_json_obj = [{"task_id": "t1", "input_data_id": "i1", "annotation_id": "a1", "attributes": {"occluded": True}}]
258
+ input_group = parser.add_mutually_exclusive_group(required=True)
259
+ input_group.add_argument(
260
+ "--json",
261
+ type=str,
262
+ help="各アノテーションごとに変更内容を記載したJSONリストを指定します。 ``file://`` を先頭に付けるとJSON形式のファイルを指定できます。\n"
263
+ f"(例) '{json.dumps(sample_json_obj, ensure_ascii=False)}'",
264
+ )
265
+ input_group.add_argument(
266
+ "--csv",
267
+ type=str,
268
+ help="各アノテーションごとに変更内容を記載したCSVファイルを指定します。 \n"
269
+ "* `task_id`, `input_data_id`, `annotation_id`, `attributes` の4つのカラムが必要です。\n"
270
+ f"`attributes` カラムには、属性名と値を '{json.dumps({'occluded': True})}' のようにJSON形式で指定します。\n",
271
+ )
272
+
273
+ parser.add_argument(
274
+ "--force",
275
+ action="store_true",
276
+ help="指定した場合は、完了状態のタスクのアノテーションも属性値を変更します。ただし、完了状態のタスクのアノテーションを変更するには、オーナーロールを持つユーザーが実行する必要があります。",
277
+ )
278
+
279
+ parser.add_argument(
280
+ "--backup",
281
+ type=Path,
282
+ required=False,
283
+ help="アノテーションのバックアップを保存するディレクトリのパス。アノテーションの復元は ``annotation restore`` コマンドで実現できます。",
284
+ )
285
+
286
+ parser.set_defaults(subcommand_func=main)
287
+
288
+
289
+ def add_parser(subparsers: Optional[argparse._SubParsersAction] = None) -> argparse.ArgumentParser:
290
+ subcommand_name = "change_attributes_per_annotation"
291
+ subcommand_help = "各アノテーションの属性値を変更します。"
292
+ epilog = "オーナロールまたはチェッカーロールを持つユーザで実行してください。"
293
+
294
+ parser = annofabcli.common.cli.add_parser(subparsers, subcommand_name, subcommand_help, epilog=epilog)
295
+ parse_args(parser)
296
+ return parser
@@ -296,8 +296,7 @@ class DeleteAnnotationMain(CommandLineWithConfirm):
296
296
  deleted_task_count += 1
297
297
  for input_data_id, annotation_ids in sub_grouped.items():
298
298
  # 指定input_data_idの全annotationを取得
299
- # TODO どこかのタイミングで、"v=2"のアノテーションを取得するようにする
300
- editor_annotation = self.service.wrapper.get_editor_annotation_or_none(self.project_id, task_id=task_id, input_data_id=input_data_id, query_params={"v": "1"})
299
+ editor_annotation = self.service.wrapper.get_editor_annotation_or_none(self.project_id, task_id=task_id, input_data_id=input_data_id, query_params={"v": "2"})
301
300
  if editor_annotation is None:
302
301
  logger.warning(
303
302
  f"task_id='{task_id}'のタスクに、input_data_id='{input_data_id}'の入力データが含まれていません。 アノテーションの削除をスキップします。 :: annotation_ids={annotation_ids}"
@@ -27,26 +27,44 @@ class DumpAnnotationMain:
27
27
  def dump_editor_annotation(self, editor_annotation: dict[str, Any], json_path: Path) -> None:
28
28
  """
29
29
  `getEditorAnnotation` APIのレスポンスをファイルに保存する。
30
+
31
+ Args:
32
+ editor_annotation: v1, v2 のどちらの形式でも対応。
30
33
  """
31
34
  json_path.write_text(json.dumps(editor_annotation, ensure_ascii=False), encoding="utf-8")
32
-
33
35
  details = editor_annotation["details"]
34
- outer_details = [e for e in details if e["data_holding_type"] == AnnotationDataHoldingType.OUTER.value]
35
- if len(outer_details) == 0:
36
- return
37
36
 
38
- input_data_id = editor_annotation["input_data_id"]
39
- outer_dir = json_path.parent / input_data_id
40
- outer_dir.mkdir(exist_ok=True, parents=True)
37
+ if editor_annotation.get("format_version") == "2.0.0":
38
+ outer_details = [e for e in details if e["body"]["_type"] == "Outer"]
39
+ if len(outer_details) == 0:
40
+ return
41
+
42
+ input_data_id = editor_annotation["input_data_id"]
43
+ outer_dir = json_path.parent / input_data_id
44
+ outer_dir.mkdir(exist_ok=True, parents=True)
45
+ # 塗りつぶし画像など外部リソースに保存されているファイルをダウンロードする
46
+ for detail in outer_details:
47
+ annotation_id = detail["annotation_id"]
48
+ outer_file_path = outer_dir / f"{annotation_id}"
49
+ self.service.wrapper.download(detail["body"]["url"], outer_file_path)
50
+
51
+ else:
52
+ outer_details = [e for e in details if e["data_holding_type"] == AnnotationDataHoldingType.OUTER.value]
53
+ if len(outer_details) == 0:
54
+ return
55
+
56
+ input_data_id = editor_annotation["input_data_id"]
57
+ outer_dir = json_path.parent / input_data_id
58
+ outer_dir.mkdir(exist_ok=True, parents=True)
41
59
 
42
- # 塗りつぶし画像など外部リソースに保存されているファイルをダウンロードする
43
- for detail in outer_details:
44
- annotation_id = detail["annotation_id"]
45
- outer_file_path = outer_dir / f"{annotation_id}"
46
- self.service.wrapper.download(detail["url"], outer_file_path)
60
+ # 塗りつぶし画像など外部リソースに保存されているファイルをダウンロードする
61
+ for detail in outer_details:
62
+ annotation_id = detail["annotation_id"]
63
+ outer_file_path = outer_dir / f"{annotation_id}"
64
+ self.service.wrapper.download(detail["url"], outer_file_path)
47
65
 
48
66
  def dump_annotation_for_input_data(self, task_id: str, input_data_id: str, task_dir: Path) -> None:
49
- editor_annotation, _ = self.service.api.get_editor_annotation(self.project_id, task_id, input_data_id)
67
+ editor_annotation, _ = self.service.api.get_editor_annotation(self.project_id, task_id, input_data_id, query_params={"v": "2"})
50
68
  json_path = task_dir / f"{input_data_id}.json"
51
69
  self.dump_editor_annotation(editor_annotation=editor_annotation, json_path=json_path)
52
70
 
@@ -74,10 +74,17 @@ class RestoreAnnotationMain(CommandLineWithConfirm):
74
74
 
75
75
  return detail
76
76
 
77
- def parser_to_request_body(self, parser: SimpleAnnotationParser) -> dict[str, Any]:
77
+ def editor_annotation_to_request_body_v1(self, editor_annotation: dict[str, Any], parser: SimpleAnnotationParser) -> dict[str, Any]:
78
+ """
79
+ `get_editor_annotation`で取得したアノテーション(v1)を、`put_annotation` APIに渡すリクエストボディ(v1)に変換する。
80
+
81
+ Args:
82
+ editor_annotation: `get_editor_annotation`で取得したアノテーション(v1)
83
+ parser: SimpleAnnotationParserインスタンス。アノテーションのファイルを開くために利用する。
84
+ """
78
85
  # infer_missing=Trueを指定する理由:Optional型のキーが存在しない場合でも、AnnotationV1データクラスのインスタンスを生成できるようにするため
79
86
  # https://qiita.com/yuji38kwmt/items/c5b56f70da3b8a70ba31
80
- annotation: AnnotationV1 = AnnotationV1.from_dict(parser.load_json(), infer_missing=True)
87
+ annotation: AnnotationV1 = AnnotationV1.from_dict(editor_annotation, infer_missing=True)
81
88
  request_details: list[dict[str, Any]] = []
82
89
  for detail in annotation.details:
83
90
  request_detail = self._to_annotation_detail_for_request(parser, detail)
@@ -94,15 +101,48 @@ class RestoreAnnotationMain(CommandLineWithConfirm):
94
101
 
95
102
  return request_body
96
103
 
104
+ def editor_annotation_to_request_body_v2(self, editor_annotation: dict[str, Any], parser: SimpleAnnotationParser) -> dict[str, Any]:
105
+ """
106
+ `get_editor_annotation`で取得したアノテーション(v2)を、`put_annotation` APIに渡すリクエストボディ(v2)に変換する。
107
+
108
+ Args:
109
+ editor_annotation: `get_editor_annotation`で取得したアノテーション(v2)
110
+ parser: SimpleAnnotationParserインスタンス。アノテーションのファイルを開くために利用する。
111
+ """
112
+ request_details: list[dict[str, Any]] = []
113
+ for detail in editor_annotation["details"]:
114
+ new_detail = copy.deepcopy(detail)
115
+ new_detail["_type"] = "Import"
116
+ annotation_id = detail["annotation_id"]
117
+ detail_body_type = detail["body"]["_type"]
118
+ if detail_body_type == "Outer":
119
+ with parser.open_outer_file(annotation_id) as f:
120
+ # TODO content typeを確認
121
+ s3_path = self.service.wrapper.upload_data_to_s3(self.project_id, f, content_type="application/octet-stream")
122
+ new_detail["body"] = {"path": s3_path, "_type": "Outer"}
123
+ request_details.append(new_detail)
124
+ elif detail_body_type == "Inner":
125
+ request_details.append(new_detail)
126
+ else:
127
+ raise ValueError(f"detail_body_type がサポート対象外です。:: detail={detail}")
128
+
129
+ request_body = {"project_id": self.project_id, "task_id": parser.task_id, "input_data_id": parser.input_data_id, "details": request_details, "format_version": "2.0.0"}
130
+
131
+ return request_body
132
+
97
133
  def put_annotation_for_input_data(self, parser: SimpleAnnotationParser) -> bool:
98
134
  task_id = parser.task_id
99
135
  input_data_id = parser.input_data_id
100
136
 
101
- old_annotation, _ = self.service.api.get_editor_annotation(self.project_id, task_id, input_data_id)
102
-
103
137
  logger.info(f"task_id='{task_id}', input_data_id='{input_data_id}' :: アノテーションをリストアします。")
104
- request_body = self.parser_to_request_body(parser)
105
138
 
139
+ editor_annotation = parser.load_json()
140
+ if editor_annotation.get("format_version") == "2.0.0":
141
+ request_body = self.editor_annotation_to_request_body_v2(editor_annotation, parser)
142
+ else:
143
+ request_body = self.editor_annotation_to_request_body_v1(editor_annotation, parser)
144
+
145
+ old_annotation, _ = self.service.api.get_editor_annotation(self.project_id, task_id, input_data_id, query_params={"v": "2"})
106
146
  updated_datetime = old_annotation["updated_datetime"] if old_annotation is not None else None
107
147
  request_body["updated_datetime"] = updated_datetime
108
148
  self.service.api.put_annotation(self.project_id, task_id, input_data_id, request_body=request_body)
@@ -2,6 +2,7 @@ import argparse
2
2
  from typing import Optional
3
3
 
4
4
  import annofabcli.annotation.change_annotation_attributes
5
+ import annofabcli.annotation.change_annotation_attributes_per_annotation
5
6
  import annofabcli.annotation.change_annotation_properties
6
7
  import annofabcli.annotation.copy_annotation
7
8
  import annofabcli.annotation.delete_annotation
@@ -21,6 +22,7 @@ def parse_args(parser: argparse.ArgumentParser) -> None:
21
22
 
22
23
  # サブコマンドの定義
23
24
  annofabcli.annotation.change_annotation_attributes.add_parser(subparsers)
25
+ annofabcli.annotation.change_annotation_attributes_per_annotation.add_parser(subparsers)
24
26
  annofabcli.annotation.change_annotation_properties.add_parser(subparsers)
25
27
  annofabcli.annotation.copy_annotation.add_parser(subparsers)
26
28
  annofabcli.annotation.delete_annotation.add_parser(subparsers)
@@ -67,7 +67,7 @@ class PutCommentMain(CommandLineWithConfirm):
67
67
  """batch_update_comments に渡すリクエストボディを作成する。"""
68
68
 
69
69
  def _create_dict_annotation_id() -> dict[str, str]:
70
- content, _ = self.service.api.get_editor_annotation(self.project_id, task["task_id"], input_data_id)
70
+ content, _ = self.service.api.get_editor_annotation(self.project_id, task["task_id"], input_data_id, query_params={"v": "2"})
71
71
  details = content["details"]
72
72
  return {e["annotation_id"]: e["label_id"] for e in details}
73
73
 
@@ -375,13 +375,10 @@ class AddProps:
375
375
 
376
376
  * user_id
377
377
  * username
378
-
379
- Args:
380
- task:
381
-
382
- Returns:
383
- Task情報
378
+ * request.user_id
379
+ * request.username
384
380
 
385
381
  """
386
382
  self._add_user_info(task_history_event)
383
+ self._add_user_info(task_history_event["request"])
387
384
  return task_history_event
@@ -81,16 +81,34 @@ class ListTaskHistoryEventWithJson(CommandLine):
81
81
  main_obj = ListTaskHistoryEventWithJsonMain(self.service)
82
82
  task_history_event_list = main_obj.get_task_history_event_list(project_id, task_history_event_json=task_history_event_json, task_id_list=task_id_list)
83
83
 
84
- logger.debug(f"タスク履歴イベント一覧の件数: {len(task_history_event_list)}")
85
-
86
- if len(task_history_event_list) > 0:
87
- if arg_format == FormatArgument.CSV:
84
+ logger.debug(f"{len(task_history_event_list)} 件のタスク履歴イベントの情報を出力します。")
85
+
86
+ if arg_format == FormatArgument.CSV:
87
+ columns = [
88
+ "project_id",
89
+ "task_id",
90
+ "task_history_id",
91
+ "created_datetime",
92
+ "phase",
93
+ "phase_stage",
94
+ "status",
95
+ "account_id",
96
+ "request.status",
97
+ "request.account_id",
98
+ "request.user_id",
99
+ "request.username",
100
+ "request.last_updated_datetime",
101
+ "request.force",
102
+ ]
103
+ if len(task_history_event_list) > 0:
88
104
  df = pandas.json_normalize(task_history_event_list)
89
- self.print_csv(df)
105
+ df = df[columns]
90
106
  else:
91
- self.print_according_to_format(task_history_event_list)
107
+ df = pandas.DataFrame(columns=columns)
108
+
109
+ self.print_csv(df)
92
110
  else:
93
- logger.warning("タスク履歴イベント一覧の件数が0件であるため、出力しません。")
111
+ self.print_according_to_format(task_history_event_list)
94
112
 
95
113
  def main(self) -> None:
96
114
  args = self.args
@@ -36,6 +36,20 @@ class SimpleTaskHistoryEvent(DataClassJsonMixin):
36
36
  status: str
37
37
 
38
38
 
39
+ @dataclass
40
+ class RequestOfTaskHistoryEvent(DataClassJsonMixin):
41
+ """operateTask APIによってタスク履歴イベントが生成されたときのリクエストボディ
42
+
43
+ ただし、CLIユーザーにとって不要な情報は除いています。
44
+ """
45
+
46
+ status: str
47
+ force: bool
48
+ account_id: Optional[str]
49
+ user_id: Optional[str]
50
+ username: Optional[str]
51
+
52
+
39
53
  @dataclass
40
54
  class WorktimeFromTaskHistoryEvent(DataClassJsonMixin):
41
55
  project_id: str
@@ -48,6 +62,8 @@ class WorktimeFromTaskHistoryEvent(DataClassJsonMixin):
48
62
  worktime_hour: float
49
63
  start_event: SimpleTaskHistoryEvent
50
64
  end_event: SimpleTaskHistoryEvent
65
+ end_event_request: RequestOfTaskHistoryEvent
66
+ """operateTask APIによってタスク履歴イベントが生成されたときのリクエストボディ"""
51
67
 
52
68
 
53
69
  class ListWorktimeFromTaskHistoryEventMain:
@@ -123,6 +139,15 @@ class ListWorktimeFromTaskHistoryEventMain:
123
139
  user_id = None
124
140
  username = None
125
141
 
142
+ end_event_request_account_id = end_event["request"]["account_id"]
143
+ end_event_request_member = self.visualize.get_project_member_from_account_id(end_event_request_account_id)
144
+ if end_event_request_member is not None:
145
+ end_event_request_user_id = end_event_request_member["user_id"]
146
+ end_event_request_username = end_event_request_member["username"]
147
+ else:
148
+ end_event_request_user_id = None
149
+ end_event_request_username = None
150
+
126
151
  return WorktimeFromTaskHistoryEvent(
127
152
  # start_eventとend_eventの以下の属性は同じなので、start_eventの値を参照する
128
153
  project_id=start_event["project_id"],
@@ -143,9 +168,16 @@ class ListWorktimeFromTaskHistoryEventMain:
143
168
  created_datetime=end_event["created_datetime"],
144
169
  status=end_event["status"],
145
170
  ),
171
+ end_event_request=RequestOfTaskHistoryEvent(
172
+ status=end_event["request"]["status"],
173
+ force=end_event["request"]["force"],
174
+ account_id=end_event["request"]["account_id"],
175
+ user_id=end_event_request_user_id,
176
+ username=end_event_request_username,
177
+ ),
146
178
  )
147
179
 
148
- def _create_worktime_list(self, task_history_event_list: list[TaskHistoryEvent]) -> list[WorktimeFromTaskHistoryEvent]:
180
+ def _create_worktime_list(self, task_id: str, task_history_event_list: list[TaskHistoryEvent]) -> list[WorktimeFromTaskHistoryEvent]:
149
181
  """タスク履歴イベントから、作業時間のリストを生成する。
150
182
 
151
183
  Args:
@@ -174,7 +206,12 @@ class ListWorktimeFromTaskHistoryEventMain:
174
206
  TaskStatus.ON_HOLD.value,
175
207
  TaskStatus.COMPLETE.value,
176
208
  }:
177
- logger.warning(f"作業中状態のタスク履歴イベントに対応するタスク履歴イベントが存在しませんでした。:: start_event={start_event}, next_event={next_event}")
209
+ logger.warning(
210
+ f"task_id='{task_id}' :: 作業開始のイベント(task_history_id='{event['task_history_id']}')の次のイベント(task_history_id='{next_event['task_history_id']}')は、"
211
+ f"作業終了のイベントではないため、作業時間を算出できません。スキップします。"
212
+ f"タスク履歴イベントが不整合な状態なので、Annofabチームに問い合わせてください。 :: "
213
+ f"start_event='{start_event}', next_event='{next_event}'"
214
+ )
178
215
  i += 1
179
216
  continue
180
217
 
@@ -204,8 +241,8 @@ class ListWorktimeFromTaskHistoryEventMain:
204
241
  task_history_event_dict = self._create_task_history_event_dict(all_task_history_event_list, task_ids=task_id_set, account_ids=account_id_set)
205
242
 
206
243
  worktime_list = []
207
- for subset_event_list in task_history_event_dict.values():
208
- subset_worktime_list = self._create_worktime_list(subset_event_list)
244
+ for task_id, subset_event_list in task_history_event_dict.items():
245
+ subset_worktime_list = self._create_worktime_list(task_id, subset_event_list)
209
246
  worktime_list.extend(subset_worktime_list)
210
247
  return worktime_list
211
248
 
@@ -230,16 +267,38 @@ class ListWorktimeFromTaskHistoryEvent(CommandLine):
230
267
  )
231
268
 
232
269
  logger.debug(f"作業時間一覧の件数: {len(worktime_list)}")
233
-
234
- if len(worktime_list) > 0:
235
- dict_worktime_list = [e.to_dict() for e in worktime_list]
236
- if arg_format == FormatArgument.CSV:
237
- df = pandas.json_normalize(dict_worktime_list)
238
- self.print_csv(df)
270
+ dict_worktime_list = [e.to_dict() for e in worktime_list]
271
+
272
+ if arg_format == FormatArgument.CSV:
273
+ columns = [
274
+ "project_id",
275
+ "task_id",
276
+ "phase",
277
+ "phase_stage",
278
+ "account_id",
279
+ "user_id",
280
+ "username",
281
+ "worktime_hour",
282
+ "start_event.task_history_id",
283
+ "start_event.created_datetime",
284
+ "start_event.status",
285
+ "end_event.task_history_id",
286
+ "end_event.created_datetime",
287
+ "end_event.status",
288
+ "end_event_request.status",
289
+ "end_event_request.force",
290
+ "end_event_request.account_id",
291
+ "end_event_request.user_id",
292
+ "end_event_request.username",
293
+ ]
294
+
295
+ if len(dict_worktime_list) > 0:
296
+ df = pandas.json_normalize(dict_worktime_list)[columns]
239
297
  else:
240
- self.print_according_to_format(dict_worktime_list)
298
+ df = pandas.DataFrame(columns=columns)
299
+ self.print_csv(df)
241
300
  else:
242
- logger.warning("作業時間一覧の件数が0件であるため、出力しません。")
301
+ self.print_according_to_format(dict_worktime_list)
243
302
 
244
303
  def main(self) -> None:
245
304
  args = self.args
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: annofabcli
3
- Version: 1.104.1
3
+ Version: 1.105.1
4
4
  Summary: Utility Command Line Interface for AnnoFab
5
5
  Author: Kurusugawa Computer Inc.
6
6
  License: MIT
@@ -2,20 +2,21 @@ annofabcli/__init__.py,sha256=fdBtxy5rOI8zi26jf0hmXS5KTBjQIsm2b9ZUSAIR558,319
2
2
  annofabcli/__main__.py,sha256=83jLGTlNGoXaYU3fz9akYXoL3kgfIuaVXo_TkpK72IU,5267
3
3
  annofabcli/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  annofabcli/annotation/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
- annofabcli/annotation/annotation_query.py,sha256=loXcmHhwp0gh_4u5NhvA1x2VAbIT6UJpdIzeFHT5HlY,15566
5
+ annofabcli/annotation/annotation_query.py,sha256=VwfPWpLOpVa2SeEJ264LmCKkBGDJvpX8o7GbWIrDE0o,15712
6
6
  annofabcli/annotation/change_annotation_attributes.py,sha256=Zjqax-sb7ujJnTUVv4io0GFmZzfqx0rpN2N1W86uVTs,17092
7
+ annofabcli/annotation/change_annotation_attributes_per_annotation.py,sha256=4OkFOasRpW86akmgZOdKwnt__b-Q6uCwILInxgiy5o8,13878
7
8
  annofabcli/annotation/change_annotation_properties.py,sha256=Kp_LZ5sSoVmmjGE80ABVO3InxsXBIxiFFvVcIJNsOMk,18309
8
9
  annofabcli/annotation/copy_annotation.py,sha256=Pih2k3vvpgfT3Ovb3gZw2L_8fK_ws_wKR7ARYG5hG_8,18407
9
- annofabcli/annotation/delete_annotation.py,sha256=Yo73sl96ETMnf6uscJX4h3NU3-cT-OH-rqqXMoLlhbM,23105
10
+ annofabcli/annotation/delete_annotation.py,sha256=hQApNrx2Ci1bBWk0dRGA0oJkIgDHwl6Jy0-33gYF6jo,22989
10
11
  annofabcli/annotation/download_annotation_zip.py,sha256=P_ZpdqIaSFEmB8jjpdykcRhh2tVlHxSlXFrYreJjShE,3282
11
- annofabcli/annotation/dump_annotation.py,sha256=iiTYjbUYEJrgoSfr4oQOepVkba9O4sjvpSKYfuER318,7466
12
+ annofabcli/annotation/dump_annotation.py,sha256=CJ10zJUm9C1M1VhB_ZtdkWPncrZTx9h9JyxmLn7f3lo,8397
12
13
  annofabcli/annotation/import_annotation.py,sha256=39w-LwhNrT4-20NR5Hy-cn_cUFVVWVnMp9x1d8aITn0,33369
13
14
  annofabcli/annotation/list_annotation.py,sha256=uKcOuGC7lzd6vVbzizkiZtYdXJ7EzY0iifuiqKl2wQM,10707
14
15
  annofabcli/annotation/list_annotation_count.py,sha256=T9fbaoxWeDJIVgW_YgHRldbwrVZWiE-57lfJrDQrj80,6474
15
16
  annofabcli/annotation/merge_segmentation.py,sha256=kIsCeXtJxzd6nobQPpi0fscaRDlTx3tg1qpy5PDfSJI,18107
16
17
  annofabcli/annotation/remove_segmentation_overlap.py,sha256=JeeBY3PUcBqLYiNItEH8Ae9zhEy6cf4LldNr_4yBdjY,15991
17
- annofabcli/annotation/restore_annotation.py,sha256=_XM1BHIZL-cxwEO6zlqy_wKE3sSS9NJGpU-WzIR2c9k,14616
18
- annofabcli/annotation/subcommand_annotation.py,sha256=ku9mzb7zZilHcjf1MFV1E7EJ8OvfSUDHpcunM38teto,2122
18
+ annofabcli/annotation/restore_annotation.py,sha256=F0_p1k0Yzx0YVOuLYOS48OWutsN5ysOu3TRa3yhRdTw,17071
19
+ annofabcli/annotation/subcommand_annotation.py,sha256=PfiU4G_Col3quCXKbZpqWoUXGi5p2uB1XI9BKMwaq1I,2288
19
20
  annofabcli/annotation_specs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
21
  annofabcli/annotation_specs/add_attribute_restriction.py,sha256=jVA7ltharabR94bf71QuW0x0yVgu6-A7PHn7Dq3kUfg,6825
21
22
  annofabcli/annotation_specs/attribute_restriction.py,sha256=tXcCLWVUdmmLWFHLjtSERcgxwFhqk7_zyKZzps0oyso,10123
@@ -37,7 +38,7 @@ annofabcli/comment/delete_comment.py,sha256=NpQVrXrIPI3vjAyOZ-5Mm-Pae_uCHu8BXVEA
37
38
  annofabcli/comment/download_comment_json.py,sha256=YfqUnMgvLgVFV7FJPIXwirREkQ2E63fXeCaF4hfwk8c,2338
38
39
  annofabcli/comment/list_all_comment.py,sha256=zMZSmVe8P89WDBZE5PZn5SIv5j2OTbnP1kS-HnCxPCI,6483
39
40
  annofabcli/comment/list_comment.py,sha256=ikXOtfI9W88w7YC1GQQ1ryq2BatHelIRb8Pa5-fKbSs,6187
40
- annofabcli/comment/put_comment.py,sha256=aP1VhjwzKLY8zjHOO1ZkHc6ulwpKtAt-10fWpDn3RUo,12000
41
+ annofabcli/comment/put_comment.py,sha256=7q_GQW7nKUqzm9tf7mjhiPHfd0lBFLFvvdyF5Cn9854,12025
41
42
  annofabcli/comment/put_comment_simply.py,sha256=OwanjmQy5nqqIw-i0Gt9APdEdftM-EuZW4pRLYbIwQM,8171
42
43
  annofabcli/comment/put_inspection_comment.py,sha256=TL8o_K9LCLxqMEVPs9wNK_X4jzEiJwjr327lziQbeAM,3803
43
44
  annofabcli/comment/put_inspection_comment_simply.py,sha256=WuySt-BV4oPETBIgLwpCIH-hh8XRetIHPb6Gtnpgs7A,6805
@@ -58,7 +59,7 @@ annofabcli/common/pandas.py,sha256=IW9xqHkdRF1I6YZc7CP_9tkGxJuu1MKEXFILjhaNUU0,4
58
59
  annofabcli/common/type_util.py,sha256=i3r5pFtRYQwJrYkl1-lVQi8XOePQxTUX_cAHgBTsagM,224
59
60
  annofabcli/common/typing.py,sha256=_AcEogoaMbib0esfN2RvHshAZH0oyRb2Ro9-rbn7NJ8,330
60
61
  annofabcli/common/utils.py,sha256=Eb4DS5j_EVdZW_YnsvIgjTbgWcjHd560plck2_WgrCI,9545
61
- annofabcli/common/visualize.py,sha256=94lPfp6jc9zhCntScuzTv_UvYiokb-l_sCwB9pjW9tA,13612
62
+ annofabcli/common/visualize.py,sha256=mBPCA5vfYNbE3I1m5oHRpo-E1hsNH11KswILtYRCOKQ,13650
62
63
  annofabcli/common/annofab/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
63
64
  annofabcli/common/annofab/annotation_specs.py,sha256=h-YSnuK0riCLoJMmV-KDP3JS-_JMqx0feSvPAB3xCSI,765
64
65
  annofabcli/common/annofab/project.py,sha256=BK96o9OO961yfUvcjuHG1DeJ2bMMEYTS5ujtJCvD_4I,891
@@ -205,11 +206,11 @@ annofabcli/task_history/list_task_history.py,sha256=izzY9PzgrgJW0O3ftAoSEfqa9fK5
205
206
  annofabcli/task_history/subcommand_task_history.py,sha256=_CvM1ts60GT15iXmsb4CHOyL170ier-q-p9edVuNLvc,1066
206
207
  annofabcli/task_history_event/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
207
208
  annofabcli/task_history_event/download_task_history_event_json.py,sha256=hQLVbQ0HjdiRpXC9tEt_Py3W_b0mo3eVIa-zp0z3fgU,2434
208
- annofabcli/task_history_event/list_all_task_history_event.py,sha256=mzFqOS1WjC4_QEz6Yyp1ZvU5h-kU_PU82CtnnjQRSlY,6345
209
- annofabcli/task_history_event/list_worktime.py,sha256=J18uLrQwZuaatkA4dAr83Rz_qAF2xsx2gOFDp6ysimI,12968
209
+ annofabcli/task_history_event/list_all_task_history_event.py,sha256=EeKMyPUxGwYCFtWQHHW954ZserGm8lUqrwNnV1iX9X4,6830
210
+ annofabcli/task_history_event/list_worktime.py,sha256=Y7Pu5DP7scPf7HPt6CTiTvB1_5_Nfi1bStUIaCpkhII,15507
210
211
  annofabcli/task_history_event/subcommand_task_history_event.py,sha256=mJVJoT4RXk4HWnY7-Nrsl4If-gtaIIEXd2z7eFZwM2I,1260
211
- annofabcli-1.104.1.dist-info/METADATA,sha256=sgCezmhchWq4KIhpkiYl40lgFwg0rkPEg4aip9wgPqw,5286
212
- annofabcli-1.104.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
213
- annofabcli-1.104.1.dist-info/entry_points.txt,sha256=C2uSUc-kkLJpoK_mDL5FEMAdorLEMPfwSf8VBMYnIFM,56
214
- annofabcli-1.104.1.dist-info/licenses/LICENSE,sha256=pcqWYfxFtxBzhvKp3x9MXNM4xciGb2eFewaRhXUNHlo,1081
215
- annofabcli-1.104.1.dist-info/RECORD,,
212
+ annofabcli-1.105.1.dist-info/METADATA,sha256=1wpj99TzN1VpD56ObB9viB1goGsbs7qUMSUhQAk3DHY,5286
213
+ annofabcli-1.105.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
214
+ annofabcli-1.105.1.dist-info/entry_points.txt,sha256=C2uSUc-kkLJpoK_mDL5FEMAdorLEMPfwSf8VBMYnIFM,56
215
+ annofabcli-1.105.1.dist-info/licenses/LICENSE,sha256=pcqWYfxFtxBzhvKp3x9MXNM4xciGb2eFewaRhXUNHlo,1081
216
+ annofabcli-1.105.1.dist-info/RECORD,,