annofabcli 1.100.4__py3-none-any.whl → 1.101.0__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.
Files changed (29) hide show
  1. annofabcli/__init__.py +6 -4
  2. annofabcli/annotation/change_annotation_attributes.py +3 -2
  3. annofabcli/annotation/change_annotation_properties.py +8 -7
  4. annofabcli/annotation/copy_annotation.py +4 -4
  5. annofabcli/annotation/delete_annotation.py +254 -29
  6. annofabcli/annotation/dump_annotation.py +14 -7
  7. annofabcli/annotation/import_annotation.py +304 -227
  8. annofabcli/annotation/restore_annotation.py +7 -7
  9. annofabcli/annotation_specs/list_annotation_specs_attribute.py +1 -1
  10. annofabcli/annotation_specs/list_annotation_specs_choice.py +1 -1
  11. annofabcli/annotation_specs/list_annotation_specs_label.py +1 -1
  12. annofabcli/annotation_specs/list_annotation_specs_label_attribute.py +1 -1
  13. annofabcli/comment/delete_comment.py +7 -5
  14. annofabcli/comment/put_comment.py +1 -1
  15. annofabcli/comment/put_comment_simply.py +7 -5
  16. annofabcli/common/cli.py +10 -10
  17. annofabcli/common/download.py +28 -29
  18. annofabcli/common/image.py +4 -2
  19. annofabcli/input_data/delete_input_data.py +4 -4
  20. annofabcli/input_data/update_metadata_of_input_data.py +1 -1
  21. annofabcli/instruction/upload_instruction.py +2 -2
  22. annofabcli/statistics/visualize_statistics.py +1 -7
  23. annofabcli/supplementary/delete_supplementary_data.py +8 -4
  24. {annofabcli-1.100.4.dist-info → annofabcli-1.101.0.dist-info}/METADATA +8 -2
  25. {annofabcli-1.100.4.dist-info → annofabcli-1.101.0.dist-info}/RECORD +28 -29
  26. annofabcli/__version__.py +0 -7
  27. {annofabcli-1.100.4.dist-info → annofabcli-1.101.0.dist-info}/WHEEL +0 -0
  28. {annofabcli-1.100.4.dist-info → annofabcli-1.101.0.dist-info}/entry_points.txt +0 -0
  29. {annofabcli-1.100.4.dist-info → annofabcli-1.101.0.dist-info}/licenses/LICENSE +0 -0
annofabcli/__init__.py CHANGED
@@ -1,5 +1,7 @@
1
- from .__version__ import __version__
1
+ from importlib.metadata import PackageNotFoundError, version
2
2
 
3
- __all__ = [
4
- "__version__",
5
- ]
3
+ try:
4
+ __version__ = version(__name__)
5
+ except PackageNotFoundError:
6
+ # `uv run annofabcli --version`では、メタデータからバージョン情報を取得できないため、fallbackしたバージョンを設定する
7
+ __version__ = "0.0.0"
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
  import functools
5
+ import json
5
6
  import logging
6
7
  import multiprocessing
7
8
  import sys
@@ -345,7 +346,7 @@ def parse_args(parser: argparse.ArgumentParser) -> None:
345
346
  argument_parser.add_project_id()
346
347
  argument_parser.add_task_id()
347
348
 
348
- EXAMPLE_ANNOTATION_QUERY = '{"label": "car", "attributes":{"occluded" true} }' # noqa: N806
349
+ EXAMPLE_ANNOTATION_QUERY = {"label": "car", "attributes": {"occluded": True}} # noqa: N806
349
350
  parser.add_argument(
350
351
  "-aq",
351
352
  "--annotation_query",
@@ -353,7 +354,7 @@ def parse_args(parser: argparse.ArgumentParser) -> None:
353
354
  required=True,
354
355
  help="変更対象のアノテーションを検索する条件をJSON形式で指定します。"
355
356
  "``file://`` を先頭に付けると、JSON形式のファイルを指定できます。"
356
- f"(ex): ``{EXAMPLE_ANNOTATION_QUERY}``",
357
+ f"(ex): ``{json.dumps(EXAMPLE_ANNOTATION_QUERY)}``",
357
358
  )
358
359
 
359
360
  EXAMPLE_ATTRIBUTES = '{"occluded": false}' # noqa: N806
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
  import functools
5
+ import json
5
6
  import logging
6
7
  import multiprocessing
7
8
  import sys
@@ -156,7 +157,7 @@ class ChangePropertiesOfAnnotationMain(CommandLineWithConfirm):
156
157
  return False
157
158
 
158
159
  logger.debug(
159
- f"{logger_prefix}task_id={task_id}, phase={dict_task['phase']}, status={dict_task['status']}, "
160
+ f"{logger_prefix}task_id='{task_id}', phase={dict_task['phase']}, status={dict_task['status']}, "
160
161
  f"updated_datetime={dict_task['updated_datetime']}"
161
162
  )
162
163
 
@@ -202,10 +203,10 @@ class ChangePropertiesOfAnnotationMain(CommandLineWithConfirm):
202
203
 
203
204
  try:
204
205
  self.change_annotation_properties(task_id, annotation_list, properties)
205
- logger.info(f"{logger_prefix}task_id={task_id}: アノテーションのプロパティを変更しました。")
206
+ logger.info(f"{logger_prefix}task_id='{task_id}' :: アノテーションのプロパティを変更しました。")
206
207
  return True # noqa: TRY300
207
208
  except Exception: # pylint: disable=broad-except
208
- logger.warning(f"task_id={task_id}: アノテーションのプロパティの変更に失敗しました。", exc_info=True)
209
+ logger.warning(f"task_id='{task_id}' :: アノテーションのプロパティの変更に失敗しました。", exc_info=True)
209
210
  return False
210
211
  finally:
211
212
  if changed_operator:
@@ -234,7 +235,7 @@ class ChangePropertiesOfAnnotationMain(CommandLineWithConfirm):
234
235
  task_index=task_index,
235
236
  )
236
237
  except Exception: # pylint: disable=broad-except
237
- logger.warning(f"task_id={task_id}: アノテーションのプロパティの変更に失敗しました。", exc_info=True)
238
+ logger.warning(f"task_id='{task_id}' :: アノテーションのプロパティの変更に失敗しました。", exc_info=True)
238
239
  return False
239
240
 
240
241
  def change_annotation_properties_task_list( # noqa: ANN201
@@ -277,7 +278,7 @@ class ChangePropertiesOfAnnotationMain(CommandLineWithConfirm):
277
278
  if result:
278
279
  success_count += 1
279
280
  except Exception: # pylint: disable=broad-except
280
- logger.warning(f"task_id={task_id}: アノテーションのプロパティの変更に失敗しました。", exc_info=True)
281
+ logger.warning(f"task_id='{task_id}' :: アノテーションのプロパティの変更に失敗しました。", exc_info=True)
281
282
  continue
282
283
 
283
284
  logger.info(f"{success_count} / {len(task_id_list)} 件のタスクに対してアノテーションのプロパティを変更しました。")
@@ -359,14 +360,14 @@ def parse_args(parser: argparse.ArgumentParser) -> None:
359
360
  argument_parser.add_project_id()
360
361
  argument_parser.add_task_id()
361
362
 
362
- EXAMPLE_ANNOTATION_QUERY = '{"label": "car", "attributes":{"occluded" true} }' # noqa: N806
363
+ EXAMPLE_ANNOTATION_QUERY = {"label": "car", "attributes": {"occluded": True}} # noqa: N806
363
364
 
364
365
  parser.add_argument(
365
366
  "-aq",
366
367
  "--annotation_query",
367
368
  type=str,
368
369
  required=False,
369
- help=f"変更対象のアノテーションを検索する条件をJSON形式で指定します。(ex): ``{EXAMPLE_ANNOTATION_QUERY}``",
370
+ help=f"変更対象のアノテーションを検索する条件をJSON形式で指定します。(ex): ``{json.dumps(EXAMPLE_ANNOTATION_QUERY)}``",
370
371
  )
371
372
 
372
373
  EXAMPLE_PROPERTIES = '{"is_protected": true}' # noqa: N806
@@ -48,11 +48,11 @@ class CopyTarget(CopyTargetMixin, ABC):
48
48
  @dataclass(frozen=True)
49
49
  class CopyTargetByTask(CopyTarget):
50
50
  @property
51
- def src(self): # noqa: ANN201
51
+ def src(self) -> str:
52
52
  return f"{self.src_task_id}"
53
53
 
54
54
  @property
55
- def dest(self): # noqa: ANN201
55
+ def dest(self) -> str:
56
56
  return f"{self.dest_task_id}"
57
57
 
58
58
 
@@ -62,11 +62,11 @@ class CopyTargetByInputData(CopyTarget):
62
62
  dest_input_data_id: str
63
63
 
64
64
  @property
65
- def src(self): # noqa: ANN201
65
+ def src(self) -> str:
66
66
  return f"{self.src_task_id}/{self.src_input_data_id}"
67
67
 
68
68
  @property
69
- def dest(self): # noqa: ANN201
69
+ def dest(self) -> str:
70
70
  return f"{self.dest_task_id}/{self.dest_input_data_id}"
71
71
 
72
72
 
@@ -1,12 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import json
4
5
  import logging
5
6
  import sys
7
+ from collections import defaultdict
8
+ from dataclasses import dataclass
6
9
  from pathlib import Path
7
10
  from typing import Any, Optional
8
11
 
9
12
  import annofabapi
13
+ import pandas
10
14
  import requests
11
15
  from annofabapi.dataclass.task import Task
12
16
  from annofabapi.models import ProjectMemberRole, TaskStatus
@@ -27,6 +31,17 @@ from annofabcli.common.facade import AnnofabApiFacade
27
31
  logger = logging.getLogger(__name__)
28
32
 
29
33
 
34
+ @dataclass(frozen=True)
35
+ class DeletedAnnotationInfo:
36
+ """
37
+ 削除対象のアノテーション情報
38
+ """
39
+
40
+ task_id: str
41
+ input_data_id: str
42
+ annotation_id: str
43
+
44
+
30
45
  class DeleteAnnotationMain(CommandLineWithConfirm):
31
46
  """アノテーション削除処理用のクラス
32
47
 
@@ -49,7 +64,7 @@ class DeleteAnnotationMain(CommandLineWithConfirm):
49
64
  self.project_id = project_id
50
65
  self.dump_annotation_obj = DumpAnnotationMain(service, project_id)
51
66
 
52
- def delete_annotation_list(self, annotation_list: list[dict[str, Any]]): # noqa: ANN201
67
+ def delete_annotation_list(self, annotation_list: list[dict[str, Any]]) -> None:
53
68
  """
54
69
  アノテーション一覧を削除する。
55
70
 
@@ -108,19 +123,22 @@ class DeleteAnnotationMain(CommandLineWithConfirm):
108
123
  """
109
124
  dict_task = self.service.wrapper.get_task_or_none(self.project_id, task_id)
110
125
  if dict_task is None:
111
- logger.warning(f"task_id = '{task_id}' は存在しません。")
126
+ logger.warning(f"task_id='{task_id}'であるタスクは存在しません。")
112
127
  return
113
128
 
114
129
  task: Task = Task.from_dict(dict_task)
115
- logger.info(f"task_id={task.task_id}, phase={task.phase.value}, status={task.status.value}, updated_datetime={task.updated_datetime}")
130
+ logger.info(f"task_id='{task.task_id}', phase='{task.phase.value}', status='{task.status.value}', updated_datetime='{task.updated_datetime}'")
116
131
 
117
132
  if task.status == TaskStatus.WORKING:
118
- logger.warning(f"task_id={task_id}: タスクが作業中状態のため、スキップします。")
133
+ logger.info(f"task_id='{task_id}' :: タスクが作業中状態のため、スキップします。")
119
134
  return
120
135
 
121
136
  if not self.is_force: # noqa: SIM102
122
137
  if task.status == TaskStatus.COMPLETE:
123
- logger.warning(f"task_id={task_id}: タスクが完了状態のため、スキップします。")
138
+ logger.info(
139
+ f"task_id='{task_id}' :: タスクが完了状態のため、スキップします。"
140
+ f"完了状態のタスクのアノテーションを削除するには、`--force`オプションを指定してください。"
141
+ )
124
142
  return
125
143
 
126
144
  annotation_list = self.get_annotation_list_for_task(task_id, annotation_query=annotation_query)
@@ -137,16 +155,16 @@ class DeleteAnnotationMain(CommandLineWithConfirm):
137
155
 
138
156
  try:
139
157
  self.delete_annotation_list(annotation_list=annotation_list)
140
- logger.info(f"task_id={task_id}: アノテーションを削除しました。")
158
+ logger.info(f"task_id='{task_id}' :: アノテーションを削除しました。")
141
159
  except requests.HTTPError:
142
- logger.warning(f"task_id={task_id}: アノテーションの削除に失敗しました。", exc_info=True)
160
+ logger.warning(f"task_id='{task_id}' :: アノテーションの削除に失敗しました。", exc_info=True)
143
161
 
144
- def delete_annotation_for_task_list( # noqa: ANN201
162
+ def delete_annotation_for_task_list(
145
163
  self,
146
164
  task_id_list: list[str],
147
165
  annotation_query: Optional[AnnotationQueryForAPI] = None,
148
166
  backup_dir: Optional[Path] = None,
149
- ):
167
+ ) -> None:
150
168
  project_title = self.facade.get_project_title(self.project_id)
151
169
  logger.info(f"プロジェクト'{project_title}'に対して、タスク{len(task_id_list)} 件のアノテーションを削除します。")
152
170
 
@@ -161,6 +179,160 @@ class DeleteAnnotationMain(CommandLineWithConfirm):
161
179
  backup_dir=backup_dir,
162
180
  )
163
181
 
182
+ def delete_annotation_by_annotation_ids(
183
+ self,
184
+ editor_annotation: dict[str, Any],
185
+ annotation_ids: set[str],
186
+ ) -> tuple[int, int]:
187
+ """
188
+ 指定してフレームに対して、annotation_idのリストで指定されたアノテーションを削除する。
189
+
190
+ Returns:
191
+ 削除したアノテーションの件数
192
+ 削除しなかったアノテーションの件数
193
+ """
194
+ if not annotation_ids:
195
+ raise ValueError("`annotation_ids` に少なくとも1件のIDを指定してください。")
196
+
197
+ task_id = editor_annotation["task_id"]
198
+ input_data_id = editor_annotation["input_data_id"]
199
+ if len(editor_annotation["details"]) == 0:
200
+ logger.warning(
201
+ f"task_id='{task_id}', input_data_id='{input_data_id}' にはアノテーションが存在しません。以下のアノテーションの削除をスキップします。 :: " # noqa: E501
202
+ f"annotation_ids={annotation_ids}"
203
+ )
204
+ return 0, len(annotation_ids)
205
+
206
+ # annotation_idでフィルタ
207
+ filtered_details = [e for e in editor_annotation["details"] if e["annotation_id"] in annotation_ids]
208
+ existent_annotation_ids = {detail["annotation_id"] for detail in filtered_details}
209
+ nonexistent_annotation_ids = annotation_ids - existent_annotation_ids
210
+
211
+ if len(nonexistent_annotation_ids) > 0:
212
+ logger.warning(
213
+ f"次のアノテーションは存在しないので、削除できません。 :: task_id='{task_id}', input_data_id='{input_data_id}', annotation_id='{nonexistent_annotation_ids}'" # noqa: E501
214
+ )
215
+
216
+ if len(filtered_details) == 0:
217
+ logger.info(f"task_id='{task_id}', input_data_id='{input_data_id}' には削除対象のアノテーションが存在しないので、スキップします。")
218
+ return 0, len(annotation_ids)
219
+
220
+ try:
221
+
222
+ def _to_request_body_elm(detail: dict[str, Any]) -> dict[str, Any]:
223
+ return {
224
+ "project_id": self.project_id,
225
+ "task_id": task_id,
226
+ "input_data_id": input_data_id,
227
+ "updated_datetime": editor_annotation["updated_datetime"],
228
+ "annotation_id": detail["annotation_id"],
229
+ "_type": "Delete",
230
+ }
231
+
232
+ request_body = [_to_request_body_elm(detail) for detail in filtered_details]
233
+ # APIを呼び出してアノテーションを削除
234
+ self.service.api.batch_update_annotations(self.project_id, request_body=request_body, query_params={"v": "2"})
235
+
236
+ logger.info(f"task_id='{task_id}', input_data_id='{input_data_id}' に含まれるアノテーション {len(filtered_details)} 件を削除しました。")
237
+
238
+ return len(filtered_details), len(nonexistent_annotation_ids)
239
+
240
+ except requests.HTTPError:
241
+ logger.warning(f"task_id='{task_id}', input_data_id='{input_data_id}' :: アノテーションの削除に失敗しました。", exc_info=True)
242
+ # `batchUpdateAnnotations` APIでエラーになった場合、途中までは削除されるので、`len(annotation_ids)` 件削除に失敗したとは限らない。
243
+ # そのため、再度アノテーション情報を取得して、削除できたアノテーション数と削除できなかったアノテーション数を取得する。
244
+ new_editor_annotation, _ = self.service.api.get_editor_annotation(
245
+ self.project_id, task_id=task_id, input_data_id=input_data_id, query_params={"v": "2"}
246
+ )
247
+ new_annotation_ids = {e["annotation_id"] for e in new_editor_annotation["details"]}
248
+ deleted_annotation_count = len(existent_annotation_ids - new_annotation_ids)
249
+ failed_to_delete_annotation_count = len(existent_annotation_ids) - deleted_annotation_count
250
+ return deleted_annotation_count, failed_to_delete_annotation_count + len(nonexistent_annotation_ids)
251
+
252
+ def delete_annotation_by_id_list(
253
+ self,
254
+ annotation_list: list[DeletedAnnotationInfo],
255
+ backup_dir: Optional[Path] = None,
256
+ ) -> None:
257
+ """
258
+ task_id, input_data_id, annotation_id のリストで指定されたアノテーションのみ削除する
259
+
260
+ Args:
261
+ annotation_list: 削除対象のアノテーションlist
262
+ backup_dir: バックアップディレクトリ
263
+ """
264
+
265
+ # task_id, input_data_idごとにまとめる
266
+ grouped: dict[str, dict[str, list[str]]] = defaultdict(lambda: defaultdict(list))
267
+ for item in annotation_list:
268
+ grouped[item.task_id][item.input_data_id].append(item.annotation_id)
269
+
270
+ total = len(annotation_list)
271
+
272
+ deleted_annotation_count = 0
273
+ failed_to_delete_annotation_count = 0
274
+
275
+ task_count = len(grouped)
276
+ deleted_task_count = 0
277
+
278
+ logger.info(f"{task_count} 件のタスクに含まれるアノテーションを削除します。")
279
+
280
+ for task_id, sub_grouped in grouped.items():
281
+ annotation_count = sum(len(v) for v in sub_grouped.values())
282
+ task = self.service.wrapper.get_task_or_none(self.project_id, task_id)
283
+ if task is None:
284
+ logger.warning(f"task_id='{task_id}' :: タスクが存在しないため、アノテーション {annotation_count} 件の削除をスキップします。")
285
+ failed_to_delete_annotation_count += annotation_count
286
+ continue
287
+
288
+ if task["status"] == TaskStatus.WORKING.value:
289
+ logger.info(f"task_id='{task_id}' :: タスクが作業中状態のため、アノテーション {annotation_count} 件の削除をスキップします。")
290
+ failed_to_delete_annotation_count += annotation_count
291
+ continue
292
+
293
+ if not self.is_force: # noqa: SIM102
294
+ if task["status"] == TaskStatus.COMPLETE.value:
295
+ logger.info(
296
+ f"task_id='{task_id}' :: タスクが完了状態のため、アノテーション {annotation_count} 件の削除をスキップします。"
297
+ f"完了状態のタスクのアノテーションを削除するには、`--force`オプションを指定してください。"
298
+ )
299
+ failed_to_delete_annotation_count += annotation_count
300
+ continue
301
+
302
+ if not self.confirm_processing(f"task_id='{task_id}'のタスクに含まれるアノテーション {annotation_count} 件を削除しますか?"):
303
+ failed_to_delete_annotation_count += annotation_count
304
+ continue
305
+
306
+ deleted_task_count += 1
307
+ for input_data_id, annotation_ids in sub_grouped.items():
308
+ # 指定input_data_idの全annotationを取得
309
+ # TODO どこかのタイミングで、"v=2"のアノテーションを取得するようにする
310
+ editor_annotation = self.service.wrapper.get_editor_annotation_or_none(
311
+ self.project_id, task_id=task_id, input_data_id=input_data_id, query_params={"v": "1"}
312
+ )
313
+ if editor_annotation is None:
314
+ logger.warning(
315
+ f"task_id='{task_id}'のタスクに、input_data_id='{input_data_id}'の入力データが含まれていません。 アノテーションの削除をスキップします。 :: " # noqa: E501
316
+ f"annotation_ids={annotation_ids}"
317
+ )
318
+ failed_to_delete_annotation_count += len(annotation_ids)
319
+ continue
320
+
321
+ if backup_dir is not None:
322
+ (backup_dir / task_id).mkdir(exist_ok=True, parents=True)
323
+ self.dump_annotation_obj.dump_editor_annotation(editor_annotation, json_path=backup_dir / task_id / f"{input_data_id}.json")
324
+
325
+ sub_deleted_annotation_count, sub_failed_to_delete_annotation_count = self.delete_annotation_by_annotation_ids(
326
+ editor_annotation, set(annotation_ids)
327
+ )
328
+ deleted_annotation_count += sub_deleted_annotation_count
329
+ failed_to_delete_annotation_count += sub_failed_to_delete_annotation_count
330
+
331
+ logger.info(
332
+ f"{deleted_task_count}/{task_count} 件のタスクに含まれている {deleted_annotation_count}/{total} 件のアノテーションを削除しました。"
333
+ f"{failed_to_delete_annotation_count} 件のアノテーションは削除できませんでした。"
334
+ )
335
+
164
336
 
165
337
  class DeleteAnnotation(CommandLine):
166
338
  """
@@ -169,22 +341,9 @@ class DeleteAnnotation(CommandLine):
169
341
 
170
342
  COMMON_MESSAGE = "annofabcli annotation delete: error:"
171
343
 
172
- def main(self) -> None:
344
+ def main(self) -> None: # noqa: PLR0912
173
345
  args = self.args
174
346
  project_id = args.project_id
175
- task_id_list = annofabcli.common.cli.get_list_from_args(args.task_id)
176
-
177
- if args.annotation_query is not None:
178
- annotation_specs, _ = self.service.api.get_annotation_specs(project_id, query_params={"v": "2"})
179
- try:
180
- dict_annotation_query = get_json_from_args(args.annotation_query)
181
- annotation_query_for_cli = AnnotationQueryForCLI.from_dict(dict_annotation_query)
182
- annotation_query = annotation_query_for_cli.to_query_for_api(annotation_specs)
183
- except ValueError as e:
184
- print(f"{self.COMMON_MESSAGE} argument '--annotation_query' の値が不正です。{e}", file=sys.stderr) # noqa: T201
185
- sys.exit(COMMAND_LINE_ERROR_STATUS_CODE)
186
- else:
187
- annotation_query = None
188
347
 
189
348
  if args.backup is None:
190
349
  print( # noqa: T201
@@ -198,9 +357,55 @@ class DeleteAnnotation(CommandLine):
198
357
  backup_dir = Path(args.backup)
199
358
 
200
359
  super().validate_project(project_id, [ProjectMemberRole.OWNER])
201
-
202
360
  main_obj = DeleteAnnotationMain(self.service, project_id, all_yes=args.yes, is_force=args.force)
203
- main_obj.delete_annotation_for_task_list(task_id_list, annotation_query=annotation_query, backup_dir=backup_dir)
361
+
362
+ if args.json is not None:
363
+ dict_annotation_list = get_json_from_args(args.json)
364
+ if not isinstance(dict_annotation_list, list):
365
+ print(f"{self.COMMON_MESSAGE} argument --json: JSON形式が不正です。オブジェクトの配列を指定してください。", file=sys.stderr) # noqa: T201
366
+ sys.exit(COMMAND_LINE_ERROR_STATUS_CODE)
367
+
368
+ try:
369
+ annotation_list = [DeletedAnnotationInfo(**eml) for eml in dict_annotation_list]
370
+ except TypeError as e:
371
+ print(f"{self.COMMON_MESSAGE} argument --json: 無効なオブジェクト形式です。{e}", file=sys.stderr) # noqa: T201
372
+ sys.exit(COMMAND_LINE_ERROR_STATUS_CODE)
373
+ main_obj.delete_annotation_by_id_list(annotation_list, backup_dir=backup_dir)
374
+
375
+ elif args.csv is not None:
376
+ csv_path = Path(args.csv)
377
+ if not csv_path.exists():
378
+ print(f"{self.COMMON_MESSAGE} argument --csv: ファイルパスが存在しません。 '{args.csv}'", file=sys.stderr) # noqa: T201
379
+ sys.exit(COMMAND_LINE_ERROR_STATUS_CODE)
380
+
381
+ df: pandas.DataFrame = pandas.read_csv(
382
+ args.csv,
383
+ dtype={"task_id": "string", "input_data_id": "string", "annotation_id": "string"},
384
+ )
385
+ required_cols = {"task_id", "input_data_id", "annotation_id"}
386
+ if not required_cols.issubset(df.columns):
387
+ print(f"{self.COMMON_MESSAGE} argument --csv: CSVに必須列がありません。{required_cols}", file=sys.stderr) # noqa: T201
388
+ sys.exit(COMMAND_LINE_ERROR_STATUS_CODE)
389
+
390
+ annotation_list = [DeletedAnnotationInfo(**eml) for eml in df.to_dict(orient="records")]
391
+ main_obj.delete_annotation_by_id_list(annotation_list, backup_dir=backup_dir)
392
+
393
+ elif args.task_id is not None:
394
+ task_id_list = annofabcli.common.cli.get_list_from_args(args.task_id)
395
+
396
+ if args.annotation_query is not None:
397
+ annotation_specs, _ = self.service.api.get_annotation_specs(project_id, query_params={"v": "2"})
398
+ try:
399
+ dict_annotation_query = get_json_from_args(args.annotation_query)
400
+ annotation_query_for_cli = AnnotationQueryForCLI.from_dict(dict_annotation_query)
401
+ annotation_query = annotation_query_for_cli.to_query_for_api(annotation_specs)
402
+ except ValueError as e:
403
+ print(f"{self.COMMON_MESSAGE} argument '--annotation_query' の値が不正です。{e}", file=sys.stderr) # noqa: T201
404
+ sys.exit(COMMAND_LINE_ERROR_STATUS_CODE)
405
+ else:
406
+ annotation_query = None
407
+
408
+ main_obj.delete_annotation_for_task_list(task_id_list, annotation_query=annotation_query, backup_dir=backup_dir)
204
409
 
205
410
 
206
411
  def main(args: argparse.Namespace) -> None:
@@ -213,21 +418,41 @@ def parse_args(parser: argparse.ArgumentParser) -> None:
213
418
  argument_parser = ArgumentParser(parser)
214
419
 
215
420
  argument_parser.add_project_id()
216
- argument_parser.add_task_id()
217
421
 
218
- EXAMPLE_ANNOTATION_QUERY = '{"label": "car", "attributes":{"occluded" true} }' # noqa: N806
422
+ group = parser.add_mutually_exclusive_group(required=True)
423
+ group.add_argument(
424
+ "-t",
425
+ "--task_id",
426
+ type=str,
427
+ nargs="+",
428
+ help="削除対象のタスクのtask_idを指定します。 ``file://`` を先頭に付けると、task_idの一覧が記載されたファイルを指定できます。",
429
+ )
430
+
431
+ example_json = [{"task_id": "t1", "input_data_id": "i1", "annotation_id": "a1"}]
432
+ group.add_argument(
433
+ "--json",
434
+ type=str,
435
+ help=f"削除対象のアノテーションをJSON配列で指定します。例: ``{json.dumps(example_json)}``",
436
+ )
437
+ group.add_argument(
438
+ "--csv",
439
+ type=str,
440
+ help="削除対象のアノテーションを記載したCSVファイルを指定します。例: task_id,input_data_id,annotation_id",
441
+ )
219
442
 
443
+ EXAMPLE_ANNOTATION_QUERY = {"label": "car", "attributes": {"occluded": True}} # noqa: N806
220
444
  parser.add_argument(
221
445
  "-aq",
222
446
  "--annotation_query",
223
447
  type=str,
224
448
  required=False,
225
449
  help="削除対象のアノテーションを検索する条件をJSON形式で指定します。"
450
+ "``--csv`` または ``--json`` を指定した場合は、このオプションは無視されます。"
226
451
  "``file://`` を先頭に付けると、JSON形式のファイルを指定できます。"
227
- f"(ex): ``{EXAMPLE_ANNOTATION_QUERY}``",
452
+ f"(ex): ``{json.dumps(EXAMPLE_ANNOTATION_QUERY)}``",
228
453
  )
229
454
 
230
- parser.add_argument("--force", action="store_true", help="完了状態のタスクのアノテーションを削除します。")
455
+ parser.add_argument("--force", action="store_true", help="指定した場合は、完了状態のタスクのアノテーションも削除します。")
231
456
  parser.add_argument(
232
457
  "--backup",
233
458
  type=str,
@@ -6,7 +6,7 @@ import json
6
6
  import logging
7
7
  import multiprocessing
8
8
  from pathlib import Path
9
- from typing import Optional
9
+ from typing import Any, Optional
10
10
 
11
11
  import annofabapi
12
12
  from annofabapi.models import AnnotationDataHoldingType
@@ -24,17 +24,19 @@ class DumpAnnotationMain:
24
24
  self.facade = AnnofabApiFacade(service)
25
25
  self.project_id = project_id
26
26
 
27
- def dump_annotation_for_input_data(self, task_id: str, input_data_id: str, task_dir: Path) -> None:
28
- annotation, _ = self.service.api.get_editor_annotation(self.project_id, task_id, input_data_id)
29
- json_path = task_dir / f"{input_data_id}.json"
30
- json_path.write_text(json.dumps(annotation, ensure_ascii=False), encoding="utf-8")
27
+ def dump_editor_annotation(self, editor_annotation: dict[str, Any], json_path: Path) -> None:
28
+ """
29
+ `getEditorAnnotation` APIのレスポンスをファイルに保存する。
30
+ """
31
+ json_path.write_text(json.dumps(editor_annotation, ensure_ascii=False), encoding="utf-8")
31
32
 
32
- details = annotation["details"]
33
+ details = editor_annotation["details"]
33
34
  outer_details = [e for e in details if e["data_holding_type"] == AnnotationDataHoldingType.OUTER.value]
34
35
  if len(outer_details) == 0:
35
36
  return
36
37
 
37
- outer_dir = task_dir / input_data_id
38
+ input_data_id = editor_annotation["input_data_id"]
39
+ outer_dir = json_path.parent / input_data_id
38
40
  outer_dir.mkdir(exist_ok=True, parents=True)
39
41
 
40
42
  # 塗りつぶし画像など外部リソースに保存されているファイルをダウンロードする
@@ -43,6 +45,11 @@ class DumpAnnotationMain:
43
45
  outer_file_path = outer_dir / f"{annotation_id}"
44
46
  self.service.wrapper.download(detail["url"], outer_file_path)
45
47
 
48
+ 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)
50
+ json_path = task_dir / f"{input_data_id}.json"
51
+ self.dump_editor_annotation(editor_annotation=editor_annotation, json_path=json_path)
52
+
46
53
  def dump_annotation_for_task(self, task_id: str, output_dir: Path, *, task_index: Optional[int] = None) -> bool:
47
54
  """
48
55
  タスク配下のアノテーションをファイルに保存する。