annofabcli 1.109.0__py3-none-any.whl → 1.111.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.
@@ -0,0 +1,393 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import importlib.util
5
+ import json
6
+ import logging
7
+ import sys
8
+ import tempfile
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any, Callable, Optional
12
+
13
+ import annofabapi
14
+ import pandas
15
+ from annofabapi.models import ProjectMemberRole
16
+
17
+ import annofabcli.common.cli
18
+ from annofabcli.common.annofab.annotation_zip import lazy_parse_simple_annotation_by_input_data
19
+ from annofabcli.common.cli import COMMAND_LINE_ERROR_STATUS_CODE, ArgumentParser, CommandLine, build_annofabapi_resource_and_login
20
+ from annofabcli.common.download import DownloadingFile
21
+ from annofabcli.common.enums import FormatArgument
22
+ from annofabcli.common.facade import AnnofabApiFacade, TaskQuery, match_annotation_with_task_query
23
+ from annofabcli.common.utils import print_csv, print_json
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ @dataclass
29
+ class ValidationResult:
30
+ """バリデーション結果を格納するクラス"""
31
+
32
+ project_id: str
33
+ task_id: str
34
+ input_data_id: str
35
+ input_data_name: str
36
+ annotation_id: str
37
+ label_name: str
38
+ function_name: str
39
+ is_valid: bool
40
+ error_message: Optional[str] = None
41
+ detail_data: Optional[dict[str, Any]] = None
42
+
43
+
44
+ class ValidationFunctionLoader:
45
+ """ユーザー定義のバリデーション関数をロードするクラス"""
46
+
47
+ def __init__(self, validation_file_path: Path) -> None:
48
+ """
49
+ Args:
50
+ validation_file_path: バリデーション関数が定義されたPythonファイルのパス
51
+ """
52
+ self.validation_file_path = validation_file_path
53
+
54
+ def load_validation_functions(self) -> dict[str, Callable[[dict[str, Any]], bool]]:
55
+ """
56
+ バリデーション関数をロードして辞書で返す。
57
+
58
+ バリデーション関数は以下のシグネチャを持つ必要がある:
59
+ ```python
60
+ def validate_detail(detail: dict[str, Any]) -> bool:
61
+ # バリデーションロジック
62
+ return True # 正常な場合
63
+ ```
64
+
65
+ Returns:
66
+ 関数名をキーとするバリデーション関数の辞書
67
+
68
+ Raises:
69
+ ImportError: バリデーションファイルの読み込みに失敗した場合
70
+ """
71
+ if not self.validation_file_path.exists():
72
+ msg = f"バリデーションファイルが存在しません: {self.validation_file_path}"
73
+ raise FileNotFoundError(msg)
74
+
75
+ spec = importlib.util.spec_from_file_location("validation_module", self.validation_file_path)
76
+ if spec is None or spec.loader is None:
77
+ msg = f"バリデーションファイルの読み込みに失敗しました: {self.validation_file_path}"
78
+ raise ImportError(msg)
79
+
80
+ module = importlib.util.module_from_spec(spec)
81
+ spec.loader.exec_module(module)
82
+
83
+ validation_functions = {}
84
+ for name in dir(module):
85
+ obj = getattr(module, name)
86
+ if callable(obj) and name.startswith("validate_") and not name.startswith("__"):
87
+ validation_functions[name] = obj
88
+ logger.debug(f"バリデーション関数を読み込みました: {name}")
89
+
90
+ if not validation_functions:
91
+ logger.warning(f"バリデーション関数が見つかりません: {self.validation_file_path}")
92
+
93
+ return validation_functions
94
+
95
+
96
+ class AnnotationValidator:
97
+ """アノテーションのバリデーションを実行するクラス"""
98
+
99
+ def __init__(
100
+ self,
101
+ service: annofabapi.Resource,
102
+ project_id: str,
103
+ validation_functions: dict[str, Callable[[dict[str, Any]], bool]],
104
+ task_query: Optional[TaskQuery] = None,
105
+ ) -> None:
106
+ self.service = service
107
+ self.project_id = project_id
108
+ self.validation_functions = validation_functions
109
+ self.task_query = task_query
110
+ self.facade = AnnofabApiFacade(service)
111
+
112
+ def validate_annotations(self, annotation_zip_path: Path) -> list[ValidationResult]:
113
+ """
114
+ アノテーションZIPファイルを検証する。
115
+
116
+ Args:
117
+ annotation_zip_path: アノテーションZIPファイルのパス
118
+
119
+ Returns:
120
+ バリデーション結果のリスト
121
+ """
122
+ logger.info("アノテーションの検証を開始します。")
123
+ results: list[ValidationResult] = []
124
+
125
+ count_validated_annotations = 0
126
+
127
+ # アノテーションZIPからアノテーション詳細情報を取得
128
+ for parser in lazy_parse_simple_annotation_by_input_data(annotation_zip_path):
129
+ simple_annotation_detail = parser.load_json()
130
+
131
+ # タスク検索条件でフィルタリング
132
+ if self.task_query is not None and not match_annotation_with_task_query(simple_annotation_detail, self.task_query):
133
+ continue
134
+
135
+ task_id = simple_annotation_detail["task_id"]
136
+ input_data_id = simple_annotation_detail["input_data_id"]
137
+ input_data_name = simple_annotation_detail["input_data_name"]
138
+
139
+ details = simple_annotation_detail["details"]
140
+ for detail in details:
141
+ annotation_id = detail["annotation_id"]
142
+ label_name = detail["label"]
143
+
144
+ count_validated_annotations += 1
145
+ if count_validated_annotations % 1000 == 0:
146
+ logger.info(f"{count_validated_annotations} 件のアノテーションを検証しました。")
147
+
148
+ # 各バリデーション関数を実行
149
+ for function_name, validation_function in self.validation_functions.items():
150
+ try:
151
+ is_valid = validation_function(detail)
152
+ result = ValidationResult(
153
+ project_id=self.project_id,
154
+ task_id=task_id,
155
+ input_data_id=input_data_id,
156
+ input_data_name=input_data_name,
157
+ annotation_id=annotation_id,
158
+ label_name=label_name,
159
+ function_name=function_name,
160
+ is_valid=is_valid,
161
+ detail_data=detail if not is_valid else None,
162
+ )
163
+ results.append(result)
164
+
165
+ if not is_valid:
166
+ logger.debug(f"バリデーション失敗: task_id={task_id}, input_data_id={input_data_id}, annotation_id={annotation_id}, function={function_name}")
167
+
168
+ except Exception as e:
169
+ error_message = f"バリデーション関数でエラーが発生しました: {e!s}"
170
+ logger.warning(error_message, exc_info=True)
171
+ result = ValidationResult(
172
+ project_id=self.project_id,
173
+ task_id=task_id,
174
+ input_data_id=input_data_id,
175
+ input_data_name=input_data_name,
176
+ annotation_id=annotation_id,
177
+ label_name=label_name,
178
+ function_name=function_name,
179
+ is_valid=False,
180
+ error_message=error_message,
181
+ detail_data=detail,
182
+ )
183
+ results.append(result)
184
+
185
+ logger.info(f"検証完了: {count_validated_annotations} 件のアノテーションを処理しました。")
186
+ return results
187
+
188
+
189
+ class ValidateAnnotation(CommandLine):
190
+ """
191
+ アノテーションZIPをユーザー定義のバリデーション関数で検証する。
192
+ """
193
+
194
+ def __init__(self, service, facade, args) -> None: # noqa: ANN001
195
+ super().__init__(service, facade, args)
196
+
197
+ @staticmethod
198
+ def validate(args: argparse.Namespace) -> bool:
199
+ COMMON_MESSAGE = "annofabcli annotation_zip validate: error:" # noqa: N806
200
+
201
+ validation_file_path = Path(args.validation_file)
202
+ if not validation_file_path.exists():
203
+ print( # noqa: T201
204
+ f"{COMMON_MESSAGE} バリデーションファイルが存在しません: {validation_file_path}",
205
+ file=sys.stderr,
206
+ )
207
+ return False
208
+
209
+ return True
210
+
211
+ def main(self) -> None:
212
+ args = self.args
213
+ if not self.validate(args):
214
+ sys.exit(COMMAND_LINE_ERROR_STATUS_CODE)
215
+
216
+ project_id = args.project_id
217
+
218
+ # プロジェクトメンバーかどうかを確認
219
+ self.facade.validate_project(project_id, project_member_roles=[ProjectMemberRole.OWNER, ProjectMemberRole.TRAINING_DATA_USER])
220
+
221
+ # バリデーション関数をロード
222
+ try:
223
+ function_loader = ValidationFunctionLoader(Path(args.validation_file))
224
+ validation_functions = function_loader.load_validation_functions()
225
+ except (ImportError, FileNotFoundError):
226
+ logger.exception("バリデーション関数の読み込みに失敗しました")
227
+ sys.exit(COMMAND_LINE_ERROR_STATUS_CODE)
228
+
229
+ if not validation_functions:
230
+ logger.error("有効なバリデーション関数が見つかりません。")
231
+ sys.exit(COMMAND_LINE_ERROR_STATUS_CODE)
232
+
233
+ logger.info(f"読み込んだバリデーション関数: {list(validation_functions.keys())}")
234
+
235
+ # タスク検索クエリ
236
+ task_query: Optional[TaskQuery] = None
237
+ if args.task_query is not None:
238
+ dict_task_query = annofabcli.common.cli.get_json_from_args(args.task_query)
239
+ task_query = TaskQuery.from_dict(dict_task_query)
240
+
241
+ # アノテーションZIPファイルのダウンロード
242
+ if args.annotation_zip is None:
243
+ # 最新のアノテーションZIPをダウンロード
244
+ with tempfile.TemporaryDirectory() as str_temp_dir:
245
+ temp_dir = Path(str_temp_dir)
246
+ logger.info("最新のアノテーションZIPをダウンロードしています...")
247
+
248
+ downloading_obj = DownloadingFile(self.service)
249
+ annotation_zip_path = downloading_obj.download_annotation_zip_to_dir(project_id, temp_dir)
250
+
251
+ # バリデーション実行
252
+ validator = AnnotationValidator(
253
+ service=self.service,
254
+ project_id=project_id,
255
+ validation_functions=validation_functions,
256
+ task_query=task_query,
257
+ )
258
+ results = validator.validate_annotations(annotation_zip_path)
259
+ else:
260
+ # ローカルのアノテーションZIPファイルを使用
261
+ annotation_zip_path = Path(args.annotation_zip)
262
+ if not annotation_zip_path.exists():
263
+ logger.error(f"アノテーションZIPファイルが存在しません: {annotation_zip_path}")
264
+ sys.exit(COMMAND_LINE_ERROR_STATUS_CODE)
265
+
266
+ validator = AnnotationValidator(
267
+ service=self.service,
268
+ project_id=project_id,
269
+ validation_functions=validation_functions,
270
+ task_query=task_query,
271
+ )
272
+ results = validator.validate_annotations(annotation_zip_path)
273
+
274
+ # 結果の出力
275
+ self._output_results(results, args.format, args.output_file)
276
+
277
+ # サマリー表示
278
+ self._print_summary(results)
279
+
280
+ def _output_results(self, results: list[ValidationResult], format_type: str, output_file: Optional[Path]) -> None:
281
+ """バリデーション結果を出力する"""
282
+ if not results:
283
+ logger.info("バリデーション結果はありません。")
284
+ return
285
+
286
+ # DataFrameに変換
287
+ df_results = pandas.DataFrame(
288
+ [
289
+ {
290
+ "project_id": result.project_id,
291
+ "task_id": result.task_id,
292
+ "input_data_id": result.input_data_id,
293
+ "input_data_name": result.input_data_name,
294
+ "annotation_id": result.annotation_id,
295
+ "label_name": result.label_name,
296
+ "function_name": result.function_name,
297
+ "is_valid": result.is_valid,
298
+ "error_message": result.error_message,
299
+ "detail_data": json.dumps(result.detail_data, ensure_ascii=False) if result.detail_data else None,
300
+ }
301
+ for result in results
302
+ ]
303
+ )
304
+
305
+ # フォーマットに応じて出力
306
+ if format_type == "json":
307
+ if output_file is not None:
308
+ print_json(results, output=output_file, is_pretty=True)
309
+ else:
310
+ print_json(results, is_pretty=True)
311
+ elif output_file is not None:
312
+ print_csv(df_results, output=output_file)
313
+ else:
314
+ print_csv(df_results)
315
+
316
+ def _print_summary(self, results: list[ValidationResult]) -> None:
317
+ """バリデーション結果のサマリーを表示する"""
318
+ if not results:
319
+ return
320
+
321
+ df_results = pandas.DataFrame(
322
+ [
323
+ {
324
+ "function_name": result.function_name,
325
+ "is_valid": result.is_valid,
326
+ }
327
+ for result in results
328
+ ]
329
+ )
330
+
331
+ summary = df_results.groupby(["function_name", "is_valid"]).size().reset_index(name="count").pivot_table(index="function_name", columns="is_valid", values="count", fill_value=0)
332
+
333
+ logger.info("=== バリデーション結果サマリー ===")
334
+ for function_name in summary.index:
335
+ valid_count = summary.loc[function_name].get(True, 0)
336
+ invalid_count = summary.loc[function_name].get(False, 0)
337
+ total_count = valid_count + invalid_count
338
+ logger.info(f"{function_name}: 正常 {valid_count} / 異常 {invalid_count} / 合計 {total_count}")
339
+
340
+
341
+ def main(args: argparse.Namespace) -> None:
342
+ service = build_annofabapi_resource_and_login(args)
343
+ facade = AnnofabApiFacade(service)
344
+ ValidateAnnotation(service, facade, args).main()
345
+
346
+
347
+ def parse_args(parser: argparse.ArgumentParser) -> None:
348
+ argument_parser = ArgumentParser(parser)
349
+
350
+ argument_parser.add_project_id()
351
+
352
+ parser.add_argument(
353
+ "--validation_file",
354
+ type=Path,
355
+ required=True,
356
+ help=(
357
+ "バリデーション関数が定義されたPythonファイルのパスを指定してください。\n"
358
+ "ファイル内で 'validate_' から始まる関数が自動的に検出され、バリデーション関数として使用されます。\n"
359
+ "バリデーション関数は 'def validate_xxx(detail: dict[str, Any]) -> bool:' のシグネチャを持つ必要があります。"
360
+ ),
361
+ )
362
+
363
+ parser.add_argument(
364
+ "--annotation_zip",
365
+ type=Path,
366
+ help="検証対象のアノテーションZIPファイルのパスを指定してください。指定しない場合は、最新のアノテーションZIPをダウンロードして使用します。",
367
+ )
368
+
369
+ parser.add_argument(
370
+ "--task_query",
371
+ type=str,
372
+ help=(
373
+ "タスクの検索クエリをJSON形式で指定します。\n"
374
+ "``file://`` を先頭に付けると、JSON形式のファイルを指定できます。\n"
375
+ "クエリのキーは、``task_id`` , ``phase`` , ``phase_stage`` , ``status`` のみです。"
376
+ ),
377
+ )
378
+
379
+ argument_parser.add_format(choices=[FormatArgument.CSV, FormatArgument.JSON], default=FormatArgument.CSV)
380
+ argument_parser.add_output()
381
+
382
+ parser.set_defaults(subcommand_func=main)
383
+
384
+
385
+ def add_parser(subparsers: Optional[argparse._SubParsersAction] = None) -> argparse.ArgumentParser:
386
+ subcommand_name = "validate"
387
+ subcommand_help = "ユーザー定義のバリデーション関数でアノテーションZIPを検証します。"
388
+ description = "ユーザー定義のバリデーション関数でアノテーションZIPを検証します。\nバリデーション関数は 'validate_' から始まる関数名で定義してください。"
389
+ epilog = "アノテーションユーザまたはオーナロールを持つユーザで実行してください。"
390
+
391
+ parser = annofabcli.common.cli.add_parser(subparsers, subcommand_name, subcommand_help, description, epilog=epilog)
392
+ parse_args(parser)
393
+ return parser
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import datetime
2
3
  import logging.config
3
4
  from functools import partial
4
5
  from pathlib import Path
@@ -30,6 +31,11 @@ def _get_annofab_error_message(http_error: requests.HTTPError) -> Optional[str]:
30
31
  return errors[0].get("message")
31
32
 
32
33
 
34
+ def get_filename(project_id: str, file_type: str, extension: str) -> str:
35
+ timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S") # noqa: DTZ005
36
+ return f"{timestamp}--{project_id}--{file_type}.{extension}"
37
+
38
+
33
39
  class DownloadingFile:
34
40
  def __init__(self, service: annofabapi.Resource) -> None:
35
41
  self.service = service
@@ -308,3 +314,94 @@ class DownloadingFile:
308
314
  if e.response.status_code == requests.codes.not_found:
309
315
  raise DownloadingFileNotFoundError(f"project_id='{project_id}'のプロジェクトに、コメント全件ファイルが存在しないため、ダウンロードできませんでした。") from e
310
316
  raise e # noqa: TRY201
317
+
318
+ # 統一された命名規則でファイルをダウンロードする関数群
319
+ def download_annotation_zip_to_dir(
320
+ self,
321
+ project_id: str,
322
+ output_dir: Path,
323
+ *,
324
+ is_latest: bool = False,
325
+ wait_options: Optional[WaitOptions] = None,
326
+ ) -> Path:
327
+ """
328
+ アノテーションZIPをoutput_dirに統一された命名規則でダウンロードする。
329
+
330
+ Args:
331
+ project_id: プロジェクトID
332
+ output_dir: 出力ディレクトリ
333
+ is_latest: 最新版をダウンロードするかどうか
334
+ wait_options: 待機オプション
335
+
336
+ Returns:
337
+ ダウンロードされたファイルのパス
338
+ """
339
+ dest_path = output_dir / get_filename(project_id, "annotation", "zip")
340
+
341
+ self.download_annotation_zip(
342
+ project_id,
343
+ dest_path=str(dest_path),
344
+ is_latest=is_latest,
345
+ wait_options=wait_options,
346
+ )
347
+ return dest_path
348
+
349
+ def download_task_json_to_dir(
350
+ self,
351
+ project_id: str,
352
+ output_dir: Path,
353
+ *,
354
+ is_latest: bool = False,
355
+ wait_options: Optional[WaitOptions] = None,
356
+ ) -> Path:
357
+ """
358
+ タスクJSONをoutput_dirに統一された命名規則でダウンロードする。
359
+
360
+ Args:
361
+ project_id: プロジェクトID
362
+ output_dir: 出力ディレクトリ
363
+ is_latest: 最新版をダウンロードするかどうか
364
+ wait_options: 待機オプション
365
+
366
+ Returns:
367
+ ダウンロードされたファイルのパス
368
+ """
369
+ dest_path = output_dir / get_filename(project_id, "task", "json")
370
+
371
+ self.download_task_json(
372
+ project_id,
373
+ dest_path=str(dest_path),
374
+ is_latest=is_latest,
375
+ wait_options=wait_options,
376
+ )
377
+ return dest_path
378
+
379
+ def download_input_data_json_to_dir(
380
+ self,
381
+ project_id: str,
382
+ output_dir: Path,
383
+ *,
384
+ is_latest: bool = False,
385
+ wait_options: Optional[WaitOptions] = None,
386
+ ) -> Path:
387
+ """
388
+ 入力データJSONをoutput_dirに統一された命名規則でダウンロードする。
389
+
390
+ Args:
391
+ project_id: プロジェクトID
392
+ output_dir: 出力ディレクトリ
393
+ is_latest: 最新版をダウンロードするかどうか
394
+ wait_options: 待機オプション
395
+
396
+ Returns:
397
+ ダウンロードされたファイルのパス
398
+ """
399
+ dest_path = output_dir / get_filename(project_id, "input_data", "json")
400
+
401
+ self.download_input_data_json(
402
+ project_id,
403
+ dest_path=str(dest_path),
404
+ is_latest=is_latest,
405
+ wait_options=wait_options,
406
+ )
407
+ return dest_path
@@ -230,10 +230,9 @@ class ListAnnotationArea(CommandLine):
230
230
 
231
231
  def download_and_print_annotation_area(project_id: str, temp_dir: Path, *, is_latest: bool, annotation_path: Optional[Path]) -> None:
232
232
  if annotation_path is None:
233
- annotation_path = temp_dir / f"{project_id}__annotation.zip"
234
- downloading_obj.download_annotation_zip(
233
+ annotation_path = downloading_obj.download_annotation_zip_to_dir(
235
234
  project_id,
236
- dest_path=annotation_path,
235
+ temp_dir,
237
236
  is_latest=is_latest,
238
237
  )
239
238
  print_annotation_area(
@@ -571,17 +571,15 @@ class ListAnnotationAttributeFilledCount(CommandLine):
571
571
 
572
572
  downloading_obj = DownloadingFile(self.service)
573
573
 
574
- # `NamedTemporaryFile`を使わない理由: Windowsで`PermissionError`が発生するため
575
- # https://qiita.com/yuji38kwmt/items/c6f50e1fc03dafdcdda0 参考
576
- with tempfile.TemporaryDirectory() as str_temp_dir:
574
+ def download_and_process_annotation(temp_dir: Path, *, is_latest: bool, annotation_path: Optional[Path]) -> None:
577
575
  # タスク全件ファイルは、フレーム番号を参照するのに利用する
578
576
  if project_id is not None and group_by == GroupBy.INPUT_DATA_ID:
579
577
  # group_byで条件を絞り込んでいる理由:
580
578
  # タスクIDで集計する際は、フレーム番号は出力しないので、タスク全件ファイルをダウンロードする必要はないため
581
- task_json_path = Path(str_temp_dir) / f"{project_id}__task.json"
582
- downloading_obj.download_task_json(
579
+ task_json_path = downloading_obj.download_task_json_to_dir(
583
580
  project_id,
584
- dest_path=str(task_json_path),
581
+ temp_dir,
582
+ is_latest=is_latest,
585
583
  )
586
584
  else:
587
585
  task_json_path = None
@@ -600,16 +598,37 @@ class ListAnnotationAttributeFilledCount(CommandLine):
600
598
 
601
599
  if annotation_path is None:
602
600
  assert project_id is not None
603
- annotation_path = Path(str_temp_dir) / f"{project_id}__annotation.zip"
604
- downloading_obj.download_annotation_zip(
601
+ annotation_path = downloading_obj.download_annotation_zip_to_dir(
605
602
  project_id,
606
- dest_path=str(annotation_path),
607
- is_latest=args.latest,
603
+ temp_dir,
604
+ is_latest=is_latest,
608
605
  )
609
606
  func(annotation_path=annotation_path)
610
607
  else:
611
608
  func(annotation_path=annotation_path)
612
609
 
610
+ if project_id is not None:
611
+ if args.temp_dir is not None:
612
+ download_and_process_annotation(temp_dir=args.temp_dir, is_latest=args.latest, annotation_path=annotation_path)
613
+ else:
614
+ with tempfile.TemporaryDirectory() as str_temp_dir:
615
+ download_and_process_annotation(temp_dir=Path(str_temp_dir), is_latest=args.latest, annotation_path=annotation_path)
616
+ else:
617
+ # プロジェクトIDが指定されていない場合は、アノテーションパスが必須なので、一時ディレクトリは不要
618
+ assert annotation_path is not None
619
+ func = partial(
620
+ main_obj.print_annotation_count,
621
+ project_id=project_id,
622
+ task_json_path=None,
623
+ group_by=group_by,
624
+ output_format=output_format,
625
+ output_file=output_file,
626
+ target_task_ids=task_id_list,
627
+ task_query=task_query,
628
+ include_flag_attribute=args.include_flag_attribute,
629
+ )
630
+ func(annotation_path=annotation_path)
631
+
613
632
 
614
633
  def main(args: argparse.Namespace) -> None:
615
634
  service = build_annofabapi_resource_and_login(args)
@@ -671,6 +690,12 @@ def parse_args(parser: argparse.ArgumentParser) -> None:
671
690
  help="``--annotation`` を指定しないとき、最新のアノテーションzipを参照します。このオプションを指定すると、アノテーションzipを更新するのに数分待ちます。",
672
691
  )
673
692
 
693
+ parser.add_argument(
694
+ "--temp_dir",
695
+ type=Path,
696
+ help="指定したディレクトリに、アノテーションZIPなどの一時ファイルをダウンロードします。",
697
+ )
698
+
674
699
  parser.set_defaults(subcommand_func=main)
675
700
 
676
701