Skip to content

Commit 8140777

Browse files
authored
S3へのアップロードしたファイルが破損していないかのチェック (#360)
* md5 check * test * test * update test * bug fix * コメントを追加 * CheckSumErrorというexceptionに変更 * format * version up
1 parent 863449f commit 8140777

File tree

7 files changed

+130
-22
lines changed

7 files changed

+130
-22
lines changed

annofabapi/__version__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.47.2"
1+
__version__ = "0.48.0"

annofabapi/exceptions.py

+20
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,23 @@ def __init__(self, outer_file_path: str, zipfile_path: Optional[str] = None):
3030
message = f"There is no item named '{str(outer_file_path)}' in the archive '{zipfile_path}'"
3131

3232
super().__init__(message)
33+
34+
35+
class CheckSumError(AnnofabApiException):
36+
"""
37+
アップロードしたデータ(ファイルやバイナリデータ)の整合性が一致していないときのエラー。
38+
39+
Args:
40+
uploaded_data_hash: アップロード対象のデータのハッシュ値(MD5)
41+
response_etag: アップロードしたときのレスポンスヘッダ'ETag'の値
42+
43+
Attributes:
44+
uploaded_data_hash: アップロード対象のデータのハッシュ値(MD5)
45+
response_etag: アップロードしたときのレスポンスヘッダ'ETag'の値
46+
"""
47+
48+
def __init__(self, message: str, uploaded_data_hash: str, response_etag: str):
49+
self.uploaded_data_hash = uploaded_data_hash
50+
self.response_etag = response_etag
51+
52+
super().__init__(message)

annofabapi/wrapper.py

+91-16
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import asyncio
33
import copy
44
import datetime
5+
import hashlib
56
import logging
67
import mimetypes
78
import time
@@ -16,7 +17,7 @@
1617
import requests
1718

1819
from annofabapi import AnnofabApi
19-
from annofabapi.exceptions import AnnofabApiException
20+
from annofabapi.exceptions import AnnofabApiException, CheckSumError
2021
from annofabapi.models import (
2122
AdditionalData,
2223
AdditionalDataDefinitionType,
@@ -346,23 +347,38 @@ def __to_dest_annotation_detail(
346347
) -> Dict[str, Any]:
347348
"""
348349
コピー元の1個のアノテーションを、コピー先用に変換する。
349-
塗りつぶし画像の場合、S3にアップロードする。
350+
塗りつぶし画像などの外部アノテーションファイルがある場合、S3にアップロードする。
350351
351352
Notes:
352353
annotation_id をUUIDv4で生成すると、アノテーションリンク属性をコピーしたときに対応できないので、暫定的にannotation_idは維持するようにする。
354+
355+
Raises:
356+
CheckSumError: アップロードした外部アノテーションファイルのMD5ハッシュ値が、S3にアップロードしたときのレスポンスのETagに一致しない
357+
353358
"""
354359
dest_detail = detail
355360
dest_detail["account_id"] = account_id
356361
if detail["data_holding_type"] == AnnotationDataHoldingType.OUTER.value:
357-
outer_file_url = detail["url"]
358-
src_response = self._request_get_wrapper(outer_file_url)
359-
s3_path = self.upload_data_to_s3(
360-
dest_project_id, data=src_response.content, content_type=src_response.headers["Content-Type"]
361-
)
362-
logger.debug("%s に塗りつぶし画像をアップロードしました。", s3_path)
363-
dest_detail["path"] = s3_path
364-
dest_detail["url"] = None
365-
dest_detail["etag"] = None
362+
363+
try:
364+
outer_file_url = detail["url"]
365+
src_response = self._request_get_wrapper(outer_file_url)
366+
s3_path = self.upload_data_to_s3(
367+
dest_project_id, data=src_response.content, content_type=src_response.headers["Content-Type"]
368+
)
369+
logger.debug("%s に外部アノテーションファイルをアップロードしました。", s3_path)
370+
dest_detail["path"] = s3_path
371+
dest_detail["url"] = None
372+
dest_detail["etag"] = None
373+
374+
except CheckSumError as e:
375+
message = (
376+
f"外部アノテーションファイル {outer_file_url} のレスポンスのMD5ハッシュ値('{e.uploaded_data_hash}')が、"
377+
f"AWS S3にアップロードしたときのレスポンスのETag('{e.response_etag}')に一致しませんでした。アップロード時にデータが破損した可能性があります。"
378+
)
379+
raise CheckSumError(
380+
message=message, uploaded_data_hash=e.uploaded_data_hash, response_etag=e.response_etag
381+
) from e
366382

367383
return dest_detail
368384

@@ -533,6 +549,9 @@ def __to_annotation_detail_for_request(
533549
534550
Returns:
535551
552+
Raises:
553+
CheckSumError: アップロードした外部アノテーションファイルのMD5ハッシュ値が、S3にアップロードしたときのレスポンスのETagに一致しない
554+
536555
"""
537556
label_info = self.__get_label_info_from_label_name(detail["label"], annotation_specs_labels)
538557
if label_info is None:
@@ -559,10 +578,21 @@ def __to_annotation_detail_for_request(
559578

560579
if data_holding_type == AnnotationDataHoldingType.OUTER.value:
561580
data_uri = detail["data"]["data_uri"]
581+
outer_file_path = f"{parser.task_id}/{parser.input_data_id}/{data_uri}"
562582
with parser.open_outer_file(data_uri) as f:
563-
s3_path = self.upload_data_to_s3(project_id, f, content_type="image/png")
564-
dest_obj["path"] = s3_path
565-
logger.debug(f"{parser.task_id}/{parser.input_data_id}/{data_uri} をS3にアップロードしました。")
583+
try:
584+
s3_path = self.upload_data_to_s3(project_id, f, content_type="image/png")
585+
dest_obj["path"] = s3_path
586+
logger.debug(f"{outer_file_path} をS3にアップロードしました。")
587+
588+
except CheckSumError as e:
589+
message = (
590+
f"アップロードした外部アノテーションファイル'{outer_file_path}'のMD5ハッシュ値('{e.uploaded_data_hash}')が、"
591+
f"AWS S3にアップロードしたときのレスポンスのETag('{e.response_etag}')に一致しませんでした。アップロード時にデータが破損した可能性があります。"
592+
)
593+
raise CheckSumError(
594+
message=message, uploaded_data_hash=e.uploaded_data_hash, response_etag=e.response_etag
595+
) from e
566596

567597
return dest_obj
568598

@@ -830,25 +860,51 @@ def upload_file_to_s3(self, project_id: str, file_path: str, content_type: Optio
830860
831861
Returns:
832862
一時データ保存先であるS3パス
863+
864+
Raises:
865+
CheckSumError: アップロードしたファイルのMD5ハッシュ値が、S3にアップロードしたときのレスポンスのETagと一致しない
866+
833867
"""
834868

835869
# content_type を推測
836870
new_content_type = self._get_content_type(file_path, content_type)
837871
with open(file_path, "rb") as f:
838-
return self.upload_data_to_s3(project_id, data=f, content_type=new_content_type)
872+
try:
873+
return self.upload_data_to_s3(project_id, data=f, content_type=new_content_type)
874+
except CheckSumError as e:
875+
message = (
876+
f"アップロードしたファイル'{file_path}'のMD5ハッシュ値('{e.uploaded_data_hash}')が、"
877+
f"AWS S3にアップロードしたときのレスポンスのETag('{e.response_etag}')に一致しませんでした。アップロード時にデータが破損した可能性があります。"
878+
)
879+
raise CheckSumError(
880+
message=message, uploaded_data_hash=e.uploaded_data_hash, response_etag=e.response_etag
881+
) from e
839882

840883
def upload_data_to_s3(self, project_id: str, data: Any, content_type: str) -> str:
841884
"""
842885
createTempPath APIを使ってアップロード用のURLとS3パスを取得して、"data" をアップロードする。
843886
844887
Args:
845888
project_id: プロジェクトID
846-
data: アップロード対象のdata. ``requests.put`` メソッドの ``data`` 引数にそのまま渡す
889+
data: アップロード対象のdata. ``open(mode="b")`` 関数の戻り値、またはバイナリ型の値です。 ``requests.put`` メソッドの ``data`` 引数にそのまま渡します
847890
content_type: アップロードするfile objectのMIME Type.
848891
849892
Returns:
850893
一時データ保存先であるS3パス
894+
895+
Raises:
896+
CheckSumError: アップロードしたデータのMD5ハッシュ値が、S3にアップロードしたときのレスポンスのETagと一致しない
851897
"""
898+
899+
def get_md5_value_from_file(fp):
900+
md5_obj = hashlib.md5()
901+
while True:
902+
chunk = fp.read(2048 * md5_obj.block_size)
903+
if len(chunk) == 0:
904+
break
905+
md5_obj.update(chunk)
906+
return md5_obj.hexdigest()
907+
852908
# 一時データ保存先を取得
853909
content = self.api.create_temp_path(project_id, header_params={"content-type": content_type})[0]
854910

@@ -865,6 +921,25 @@ def upload_data_to_s3(self, project_id: str, data: Any, content_type: str) -> st
865921

866922
_log_error_response(logger, res_put)
867923
_raise_for_status(res_put)
924+
925+
# アップロードしたファイルが破損していなかをチェックする
926+
if hasattr(data, "read"):
927+
# 読み込み位置を先頭に戻す
928+
data.seek(0)
929+
uploaded_data_hash = get_md5_value_from_file(data)
930+
else:
931+
uploaded_data_hash = hashlib.md5(data).hexdigest()
932+
933+
# ETagにはダブルクォートが含まれているため、`str_md5`もそれに合わせる
934+
response_etag = res_put.headers["ETag"]
935+
936+
if f'"{uploaded_data_hash}"' != response_etag:
937+
message = (
938+
f"アップロードしたデータのMD5ハッシュ値('{uploaded_data_hash}')が、"
939+
f"AWS S3にアップロードしたときのレスポンスのETag('{response_etag}')に一致しませんでした。アップロード時にデータが破損した可能性があります。"
940+
)
941+
raise CheckSumError(message=message, uploaded_data_hash=uploaded_data_hash, response_etag=response_etag)
942+
868943
return content["path"]
869944

870945
def put_input_data_from_file(

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "annofabapi"
3-
version = "0.47.2"
3+
version = "0.48.0"
44
description = "Python Clinet Library of AnnoFab WebAPI (https://annofab.com/docs/api/)"
55
authors = ["yuji38kwmt"]
66
license = "MIT"

pytest.ini

+3
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,8 @@ endpoint_url = https://annofab.com
1111
# Specify AnnoFab project that has owner role assigned to you.
1212
project_id = 1ae6ec18-2a71-4eb5-9ac1-92329b01a5ca
1313
task_id = test_task_1
14+
# 変更されるタスクのtask_id
15+
changed_task_id = test_task_2
16+
1417

1518

tests/test_api.py

+14-2
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828

2929
project_id = inifile["annofab"]["project_id"]
3030
task_id = inifile["annofab"]["task_id"]
31-
31+
changed_task_id = inifile["annofab"]["changed_task_id"]
3232

3333
test_dir = "./tests/data"
3434
out_dir = "./tests/out"
@@ -84,6 +84,18 @@ def test_wrapper_copy_annotation(self):
8484
result = wrapper.copy_annotation(src, dest)
8585
assert result == True
8686

87+
def test_wrapper_put_annotation_for_simple_annotation_json(self):
88+
input_data_id = test_wrapper.get_first_input_data_id_in_task(project_id, task_id)
89+
annotation_specs_v2, _ = service.api.get_annotation_specs(project_id, query_params={"v": "2"})
90+
wrapper.put_annotation_for_simple_annotation_json(
91+
project_id,
92+
changed_task_id,
93+
input_data_id,
94+
simple_annotation_json="tests/data/simple-annotation/sample_1/c6e1c2ec-6c7c-41c6-9639-4244c2ed2839.json",
95+
annotation_specs_labels=annotation_specs_v2["labels"],
96+
annotation_specs_additionals=annotation_specs_v2["additionals"],
97+
)
98+
8799

88100
class TestAnnotationSpecs:
89101
def test_get_annotation_specs(self):
@@ -168,7 +180,7 @@ def teardown_class(cls):
168180
wrapper.change_task_status_to_break(project_id, task_id)
169181

170182

171-
class TestInput:
183+
class TestInputData:
172184
@classmethod
173185
def setup_class(cls):
174186
cls.input_data_id = test_wrapper.get_first_input_data_id_in_task(project_id, task_id)

tests/test_dataclass_webapi.py

-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import configparser
22
import os
3-
import warnings
43
from pathlib import Path
54

65
import annofabapi
@@ -122,7 +121,6 @@ def test_job(self):
122121
print(f"ジョブが存在しませんでした。")
123122

124123

125-
126124
class TestMy:
127125
def test_my_organization(self):
128126
my_organizations = service.wrapper.get_all_my_organizations()

0 commit comments

Comments
 (0)