echoss-storage 1.1.4__tar.gz
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.
- echoss_storage-1.1.4/PKG-INFO +107 -0
- echoss_storage-1.1.4/README.md +82 -0
- echoss_storage-1.1.4/echoss_storage/__init__.py +5 -0
- echoss_storage-1.1.4/echoss_storage/s3_handler.py +376 -0
- echoss_storage-1.1.4/echoss_storage.egg-info/PKG-INFO +107 -0
- echoss_storage-1.1.4/echoss_storage.egg-info/SOURCES.txt +10 -0
- echoss_storage-1.1.4/echoss_storage.egg-info/dependency_links.txt +1 -0
- echoss_storage-1.1.4/echoss_storage.egg-info/requires.txt +6 -0
- echoss_storage-1.1.4/echoss_storage.egg-info/top_level.txt +1 -0
- echoss_storage-1.1.4/setup.cfg +4 -0
- echoss_storage-1.1.4/setup.py +23 -0
- echoss_storage-1.1.4/tests/test_s3_handler.py +30 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: echoss-storage
|
|
3
|
+
Version: 1.1.4
|
|
4
|
+
Summary: echoss AI Bigdata Solution - Object Storage like S3 handler
|
|
5
|
+
Home-page:
|
|
6
|
+
Author: ckkim
|
|
7
|
+
Author-email: ckkim@12cm.co.kr
|
|
8
|
+
Keywords: echoss,object storage,s3handler,s3_handler
|
|
9
|
+
Requires-Python: >3.7
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: boto3~=1.19.0
|
|
12
|
+
Requires-Dist: opencv-python==4.8.0.74
|
|
13
|
+
Requires-Dist: numpy>=1.22.3
|
|
14
|
+
Requires-Dist: tqdm==4.63.2
|
|
15
|
+
Requires-Dist: pillow==10.1.0
|
|
16
|
+
Requires-Dist: echoss-fileformat>=1.1.0
|
|
17
|
+
Dynamic: author
|
|
18
|
+
Dynamic: author-email
|
|
19
|
+
Dynamic: description
|
|
20
|
+
Dynamic: description-content-type
|
|
21
|
+
Dynamic: keywords
|
|
22
|
+
Dynamic: requires-dist
|
|
23
|
+
Dynamic: requires-python
|
|
24
|
+
Dynamic: summary
|
|
25
|
+
|
|
26
|
+
# v1.1.3 change package name from echoss_s3handler to echoss_storage and use echoss-fileformat
|
|
27
|
+
# v1.1.4 remove S3ClientHandler, add S3ResourceFileView class, change internal implementation
|
|
28
|
+
- initial config: str or dict
|
|
29
|
+
- add internal class S3ResouceFileView as return object get_object_file()
|
|
30
|
+
- move read_file() to S3ResouceFileView as_json() or as_text()
|
|
31
|
+
- move read_image() to S3ResouceFileView as_pil_image() or as as_cv2_image()
|
|
32
|
+
|
|
33
|
+
# install 방법
|
|
34
|
+
pip install -U echoss-storage
|
|
35
|
+
|
|
36
|
+
## import 방법
|
|
37
|
+
```python
|
|
38
|
+
from echoss_storage import S3ResourceHandler
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
custom yolov7과 image_utils의 함수 사용시 S3에 있는 데이터를 사용하고자 하여 만든 Class
|
|
43
|
+
|
|
44
|
+
# Resource Class
|
|
45
|
+
내부 주요 함수
|
|
46
|
+
```
|
|
47
|
+
# s3 config 파일 읽기
|
|
48
|
+
s3_config = "../s3 config file path/ai_solution_dataset_test.yaml"
|
|
49
|
+
# class 할당
|
|
50
|
+
s3_resource = S3ResourceHandler(config, env=None)
|
|
51
|
+
|
|
52
|
+
s3_resource.get_bucket_list(data_info=False)
|
|
53
|
+
- 현재 config 파일에 해당하는 접속 계정에 있는 bucket들의 정보를 출력해준다.(버킷명, 버킷 생성날짜 등)
|
|
54
|
+
- data_info를 True로 하게 되면 출력이 버킷명이 key값, 마지막 생성일자를 vlaue로 하는 Dict형태로 출력되며,
|
|
55
|
+
false로 하면 버킷의 목록만 list형태로 출력.
|
|
56
|
+
|
|
57
|
+
s3_resource.get_object_info(bucket, file_path)
|
|
58
|
+
- parameter로 입력된 object의 마지막 수정일시(YYYY-MM-DD HH:MM:SS 꼴)와 s3link를 출력해준다.
|
|
59
|
+
|
|
60
|
+
s3_resource.get_object_list(bucket, s3_prefix, after_ts=0, pattern=None, tqdm_=True)
|
|
61
|
+
- s3_prefix는 버킷 이후 공통되는 경로 입력
|
|
62
|
+
- after_ts는 입력된 이후 추가/수정된 데이터만 추리기 위함(포맷 : "2023-02-28 00:00:00")
|
|
63
|
+
- patter은 확장자와 같은 부분
|
|
64
|
+
- object들의 path를 list로 반환한다.
|
|
65
|
+
|
|
66
|
+
s3_resource.download_object(bucket_name, target_file_path, download_file_path)
|
|
67
|
+
- S3에 있는 object를 local에 다운로드하기 위한 함수
|
|
68
|
+
- target_file_path는 다운로드 하고자하는 버킷 내의 object 경로
|
|
69
|
+
- download_file_path는 로컬에 다운받고자 하는 경로 입력(파일의 이름 포함)
|
|
70
|
+
|
|
71
|
+
s3_resource.upload_object(bucket_name, target_file_path, upload_file_path, ExtraArgs=None)
|
|
72
|
+
- locla에 있는 데이터를 S3에 업로드하기 위한 함수
|
|
73
|
+
- target_file_path는 업로드 하고자하는 로컬의 파일 경로
|
|
74
|
+
- upload_file_path는 s3에 업로드 하고자하는 경로
|
|
75
|
+
- ExtraArgs는 metadata 예를들어 {'ContentType': "video/mp4", 'ACL': "public-read"}위와 같이 입력
|
|
76
|
+
|
|
77
|
+
s3_resource.put_object(object_body, object_name, trg_bucket)
|
|
78
|
+
- 메모리상으로 저장되어있는 json혹은 txt 데이터를 S3 object로 업로드하는데 사용하는 함수
|
|
79
|
+
- object_body는 메모리 상으로 저장되어 있는 데이터
|
|
80
|
+
- object_name은 업로드 하고자하는 경로및 파일명의 path
|
|
81
|
+
- trg_bucket은 업로드 하고자하는 버킷명
|
|
82
|
+
- 이미지도 업로드할 수 있으나 이때에는 아래와 같이 이미지를 PIL, io라이브러리를 이용하여 가공한 뒤 업로드를 해야합니다.
|
|
83
|
+
img0 = Image.fromarray(im0, mode='RGB')
|
|
84
|
+
out_img = io.BytesIO()
|
|
85
|
+
img0.save(out_img, format='png')
|
|
86
|
+
out_img.seek(0)
|
|
87
|
+
|
|
88
|
+
s3_resource.s3tos3_put_object(src_file_name, trg_file_name, trg_s3_config, fin_print=True)
|
|
89
|
+
- 서로 다른 S3계정간의 데이터를 local에 따로 다운로드 한 뒤 업로드 하는 것이 아닌 메모리상으로 불러 업로드하는 함수
|
|
90
|
+
- src_file_name은 옮기고자하는 s3 파일의 전체 경로(버킷명은 안적어주어도 무방 -> class를 호출할때의 config파일에서 가지고 옴)
|
|
91
|
+
- trg_file_name은 옮겨가야할 s3에서의 경로 입력(파일명까지)
|
|
92
|
+
- trg_s3_config는 옮겨가야할 s3의 config파일(확장자는 yaml)
|
|
93
|
+
|
|
94
|
+
s3_resource.move_object(src_file_name, trg_file_name, bucket)
|
|
95
|
+
- 같은 s3내 같은 버킷에서의 파일 이동
|
|
96
|
+
- src_file_name은 원본 파일
|
|
97
|
+
- trg_file_name은 이동되어야 할 경로
|
|
98
|
+
|
|
99
|
+
s3_resource.read_file(bucket, file_name)
|
|
100
|
+
- S3에 있는 파일을 메모리로 읽기 위한 함수
|
|
101
|
+
|
|
102
|
+
s3_resource.read_image(bucket, file_name, library="cv2")
|
|
103
|
+
- S3에 있는 이미지 파일을 메모리로 읽는 함수
|
|
104
|
+
- 이후 사용되는 이미지 형식에 따라 옵션에 "cv2", "PIL"을 선택하여 사용 가능
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# v1.1.3 change package name from echoss_s3handler to echoss_storage and use echoss-fileformat
|
|
2
|
+
# v1.1.4 remove S3ClientHandler, add S3ResourceFileView class, change internal implementation
|
|
3
|
+
- initial config: str or dict
|
|
4
|
+
- add internal class S3ResouceFileView as return object get_object_file()
|
|
5
|
+
- move read_file() to S3ResouceFileView as_json() or as_text()
|
|
6
|
+
- move read_image() to S3ResouceFileView as_pil_image() or as as_cv2_image()
|
|
7
|
+
|
|
8
|
+
# install 방법
|
|
9
|
+
pip install -U echoss-storage
|
|
10
|
+
|
|
11
|
+
## import 방법
|
|
12
|
+
```python
|
|
13
|
+
from echoss_storage import S3ResourceHandler
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
custom yolov7과 image_utils의 함수 사용시 S3에 있는 데이터를 사용하고자 하여 만든 Class
|
|
18
|
+
|
|
19
|
+
# Resource Class
|
|
20
|
+
내부 주요 함수
|
|
21
|
+
```
|
|
22
|
+
# s3 config 파일 읽기
|
|
23
|
+
s3_config = "../s3 config file path/ai_solution_dataset_test.yaml"
|
|
24
|
+
# class 할당
|
|
25
|
+
s3_resource = S3ResourceHandler(config, env=None)
|
|
26
|
+
|
|
27
|
+
s3_resource.get_bucket_list(data_info=False)
|
|
28
|
+
- 현재 config 파일에 해당하는 접속 계정에 있는 bucket들의 정보를 출력해준다.(버킷명, 버킷 생성날짜 등)
|
|
29
|
+
- data_info를 True로 하게 되면 출력이 버킷명이 key값, 마지막 생성일자를 vlaue로 하는 Dict형태로 출력되며,
|
|
30
|
+
false로 하면 버킷의 목록만 list형태로 출력.
|
|
31
|
+
|
|
32
|
+
s3_resource.get_object_info(bucket, file_path)
|
|
33
|
+
- parameter로 입력된 object의 마지막 수정일시(YYYY-MM-DD HH:MM:SS 꼴)와 s3link를 출력해준다.
|
|
34
|
+
|
|
35
|
+
s3_resource.get_object_list(bucket, s3_prefix, after_ts=0, pattern=None, tqdm_=True)
|
|
36
|
+
- s3_prefix는 버킷 이후 공통되는 경로 입력
|
|
37
|
+
- after_ts는 입력된 이후 추가/수정된 데이터만 추리기 위함(포맷 : "2023-02-28 00:00:00")
|
|
38
|
+
- patter은 확장자와 같은 부분
|
|
39
|
+
- object들의 path를 list로 반환한다.
|
|
40
|
+
|
|
41
|
+
s3_resource.download_object(bucket_name, target_file_path, download_file_path)
|
|
42
|
+
- S3에 있는 object를 local에 다운로드하기 위한 함수
|
|
43
|
+
- target_file_path는 다운로드 하고자하는 버킷 내의 object 경로
|
|
44
|
+
- download_file_path는 로컬에 다운받고자 하는 경로 입력(파일의 이름 포함)
|
|
45
|
+
|
|
46
|
+
s3_resource.upload_object(bucket_name, target_file_path, upload_file_path, ExtraArgs=None)
|
|
47
|
+
- locla에 있는 데이터를 S3에 업로드하기 위한 함수
|
|
48
|
+
- target_file_path는 업로드 하고자하는 로컬의 파일 경로
|
|
49
|
+
- upload_file_path는 s3에 업로드 하고자하는 경로
|
|
50
|
+
- ExtraArgs는 metadata 예를들어 {'ContentType': "video/mp4", 'ACL': "public-read"}위와 같이 입력
|
|
51
|
+
|
|
52
|
+
s3_resource.put_object(object_body, object_name, trg_bucket)
|
|
53
|
+
- 메모리상으로 저장되어있는 json혹은 txt 데이터를 S3 object로 업로드하는데 사용하는 함수
|
|
54
|
+
- object_body는 메모리 상으로 저장되어 있는 데이터
|
|
55
|
+
- object_name은 업로드 하고자하는 경로및 파일명의 path
|
|
56
|
+
- trg_bucket은 업로드 하고자하는 버킷명
|
|
57
|
+
- 이미지도 업로드할 수 있으나 이때에는 아래와 같이 이미지를 PIL, io라이브러리를 이용하여 가공한 뒤 업로드를 해야합니다.
|
|
58
|
+
img0 = Image.fromarray(im0, mode='RGB')
|
|
59
|
+
out_img = io.BytesIO()
|
|
60
|
+
img0.save(out_img, format='png')
|
|
61
|
+
out_img.seek(0)
|
|
62
|
+
|
|
63
|
+
s3_resource.s3tos3_put_object(src_file_name, trg_file_name, trg_s3_config, fin_print=True)
|
|
64
|
+
- 서로 다른 S3계정간의 데이터를 local에 따로 다운로드 한 뒤 업로드 하는 것이 아닌 메모리상으로 불러 업로드하는 함수
|
|
65
|
+
- src_file_name은 옮기고자하는 s3 파일의 전체 경로(버킷명은 안적어주어도 무방 -> class를 호출할때의 config파일에서 가지고 옴)
|
|
66
|
+
- trg_file_name은 옮겨가야할 s3에서의 경로 입력(파일명까지)
|
|
67
|
+
- trg_s3_config는 옮겨가야할 s3의 config파일(확장자는 yaml)
|
|
68
|
+
|
|
69
|
+
s3_resource.move_object(src_file_name, trg_file_name, bucket)
|
|
70
|
+
- 같은 s3내 같은 버킷에서의 파일 이동
|
|
71
|
+
- src_file_name은 원본 파일
|
|
72
|
+
- trg_file_name은 이동되어야 할 경로
|
|
73
|
+
|
|
74
|
+
s3_resource.read_file(bucket, file_name)
|
|
75
|
+
- S3에 있는 파일을 메모리로 읽기 위한 함수
|
|
76
|
+
|
|
77
|
+
s3_resource.read_image(bucket, file_name, library="cv2")
|
|
78
|
+
- S3에 있는 이미지 파일을 메모리로 읽는 함수
|
|
79
|
+
- 이후 사용되는 이미지 형식에 따라 옵션에 "cv2", "PIL"을 선택하여 사용 가능
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import os
|
|
3
|
+
import boto3
|
|
4
|
+
import botocore.response
|
|
5
|
+
import cv2
|
|
6
|
+
import json
|
|
7
|
+
import numpy as np
|
|
8
|
+
import time
|
|
9
|
+
import datetime
|
|
10
|
+
import tqdm
|
|
11
|
+
from PIL import Image
|
|
12
|
+
from typing import Union
|
|
13
|
+
|
|
14
|
+
from echoss_fileformat import FileUtil, get_logger
|
|
15
|
+
|
|
16
|
+
logger = get_logger(logger_name='echoss_storage')
|
|
17
|
+
|
|
18
|
+
def _split_s3_key(s3_key):
|
|
19
|
+
key = str(s3_key)
|
|
20
|
+
last_name = key.split('/')[-1]
|
|
21
|
+
return key.replace(last_name, ""), last_name
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# 빈문자열 체크
|
|
25
|
+
def _is_blank(str_data:str):
|
|
26
|
+
if str_data and str_data.strip():
|
|
27
|
+
return False
|
|
28
|
+
return True
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# print시 문자에 색을 넣을 수 있는 함수
|
|
32
|
+
def colorstr(*input_data):
|
|
33
|
+
# Colors a string https://en.wikipedia.org/wiki/ANSI_escape_code, i.e. colorstr('blue', 'hello world')
|
|
34
|
+
*args, string = input_data if len(input_data) > 1 else ('blue', 'bold', input_data[0]) # color arguments, string
|
|
35
|
+
colors = {'black': '\033[30m', # basic colors
|
|
36
|
+
'red': '\033[31m',
|
|
37
|
+
'green': '\033[32m',
|
|
38
|
+
'yellow': '\033[33m',
|
|
39
|
+
'blue': '\033[34m',
|
|
40
|
+
'magenta': '\033[35m',
|
|
41
|
+
'cyan': '\033[36m',
|
|
42
|
+
'white': '\033[37m',
|
|
43
|
+
'bright_black': '\033[90m', # bright colors
|
|
44
|
+
'bright_red': '\033[91m',
|
|
45
|
+
'bright_green': '\033[92m',
|
|
46
|
+
'bright_yellow': '\033[93m',
|
|
47
|
+
'bright_blue': '\033[94m',
|
|
48
|
+
'bright_magenta': '\033[95m',
|
|
49
|
+
'bright_cyan': '\033[96m',
|
|
50
|
+
'bright_white': '\033[97m',
|
|
51
|
+
'end': '\033[0m', # misc
|
|
52
|
+
'bold': '\033[1m',
|
|
53
|
+
'underline': '\033[4m'}
|
|
54
|
+
return ''.join(colors[x] for x in args) + f'{string}' + colors['end']
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class S3ResourceHandler:
|
|
58
|
+
def __init__(self, config_file:Union[str, dict], env:str=None):
|
|
59
|
+
"""
|
|
60
|
+
:param config_file: yaml or json file_path or config dictionary
|
|
61
|
+
"""
|
|
62
|
+
if type(config_file) == dict:
|
|
63
|
+
self.config_file = config_file
|
|
64
|
+
elif type(config_file) == str and os.path.exists(config_file):
|
|
65
|
+
self.config_file = FileUtil.dict_load(config_file)
|
|
66
|
+
else:
|
|
67
|
+
logger.debug('설정 파일을 dict형식 혹은 파일의 경로로 입력해주세요.')
|
|
68
|
+
|
|
69
|
+
if env is not None and env in self.config_file:
|
|
70
|
+
self.s3_config = self.config_file[env]
|
|
71
|
+
else:
|
|
72
|
+
self.s3_config = self.config_file
|
|
73
|
+
|
|
74
|
+
if 's3' in self.s3_config:
|
|
75
|
+
self.s3_config = self.s3_config['s3']
|
|
76
|
+
|
|
77
|
+
self.s3_session = boto3.Session(
|
|
78
|
+
region_name=self.config_file['region_name'],
|
|
79
|
+
aws_access_key_id=self.config_file['access_key_id'],
|
|
80
|
+
aws_secret_access_key=self.config_file['secret_access_key'])
|
|
81
|
+
|
|
82
|
+
self.s3 = self.s3_session.resource(service_name='s3', endpoint_url=self.config_file.get('endpoint_url'))
|
|
83
|
+
|
|
84
|
+
def get_bucket_list(self, include_date:bool=False):
|
|
85
|
+
"""
|
|
86
|
+
S3ClientHandler class의 get_bucket_list 함수를 사용하며,
|
|
87
|
+
data_info 옵션을 사용하여
|
|
88
|
+
단순히 버킷의 리스트만 출력하거나, 버킷의 생성날짜까지 확인할 수 있도록 한 함수
|
|
89
|
+
:param include_date: True or False
|
|
90
|
+
:return: If date_info is True -> Output : Dict,
|
|
91
|
+
False -> Output : List
|
|
92
|
+
"""
|
|
93
|
+
bucket_list_info = self.s3.buckets.all()
|
|
94
|
+
|
|
95
|
+
if include_date:
|
|
96
|
+
result_dict = {
|
|
97
|
+
bucket_info.name: bucket_info.creation_date.strftime('%Y-%m-%d')
|
|
98
|
+
for bucket_info in bucket_list_info
|
|
99
|
+
}
|
|
100
|
+
return result_dict
|
|
101
|
+
else:
|
|
102
|
+
result_list = [
|
|
103
|
+
bucket_info.name
|
|
104
|
+
for bucket_info in bucket_list_info
|
|
105
|
+
]
|
|
106
|
+
return result_list
|
|
107
|
+
|
|
108
|
+
def select_bucket(self, bucket):
|
|
109
|
+
"""
|
|
110
|
+
s3에서 작업 하기 전 작업할 버킷을 선택하는 단계 독립적으로는 거의 사용하지 않음
|
|
111
|
+
거의 아래의 함수들에 사용
|
|
112
|
+
"""
|
|
113
|
+
s3bucket = self.s3.Bucket(bucket)
|
|
114
|
+
return s3bucket
|
|
115
|
+
|
|
116
|
+
def get_object_list(self, bucket, s3_prefix, after_ts: int or str=0, pattern=None, use_tqdm=True):
|
|
117
|
+
"""
|
|
118
|
+
after_ts 에는 "2023-02-28 00:00:00" 포맷으로 작성해야 한다.
|
|
119
|
+
:param bucket: bucket name
|
|
120
|
+
:param s3_prefix: 버킷 이후 공통되는 경로
|
|
121
|
+
:param after_ts: 입력되는 시간 이후 수정된 데이터만 추리기 위한 파라미터 ex) "2023-02-28 00:00:00"
|
|
122
|
+
:param pattern: 예를들면 확장자
|
|
123
|
+
:param use_tqdm: 예를들면 확장자
|
|
124
|
+
:return:
|
|
125
|
+
"""
|
|
126
|
+
s3bucket = self.s3.Bucket(bucket)
|
|
127
|
+
object_list = s3bucket.objects.filter(Prefix=s3_prefix)
|
|
128
|
+
|
|
129
|
+
filenames = []
|
|
130
|
+
count = 0
|
|
131
|
+
if use_tqdm:
|
|
132
|
+
iterator = tqdm.tqdm(object_list)
|
|
133
|
+
else:
|
|
134
|
+
iterator = object_list
|
|
135
|
+
|
|
136
|
+
for obj in iterator:
|
|
137
|
+
count += 1
|
|
138
|
+
if pattern is not None and not pattern in obj.key:
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
last_modified_dt = obj.last_modified
|
|
142
|
+
s3_ts = last_modified_dt.timestamp() * 1000
|
|
143
|
+
if after_ts != 0:
|
|
144
|
+
after_ts_ = datetime.datetime.strptime(after_ts, "%Y-%m-%d %H:%M:%S")
|
|
145
|
+
after_ts__ = time.mktime(after_ts_.timetuple())*1000
|
|
146
|
+
else:
|
|
147
|
+
after_ts__ = after_ts
|
|
148
|
+
if s3_ts > after_ts__:
|
|
149
|
+
s3_path, s3_filename = _split_s3_key(obj.key)
|
|
150
|
+
# directory check
|
|
151
|
+
if _is_blank(s3_filename) or s3_filename.endswith("/"):
|
|
152
|
+
pass
|
|
153
|
+
else:
|
|
154
|
+
filenames.append(s3_path + s3_filename)
|
|
155
|
+
return filenames
|
|
156
|
+
|
|
157
|
+
def download_object(self, bucket_name, target_file_path, download_file_path):
|
|
158
|
+
"""
|
|
159
|
+
버킷내의 object를 로컬로 다운로드 하기 위한 함수
|
|
160
|
+
:param bucket_name: 버킷명
|
|
161
|
+
:param target_file_path: 다운로드 하고자하는 버킷 내의 object 이름(경로)
|
|
162
|
+
:param download_file_path: 로컬에 다운로드 받고자 하는 경로(파일의 이름 포함)
|
|
163
|
+
:return:
|
|
164
|
+
"""
|
|
165
|
+
s3bucket = self.s3.Bucket(bucket_name)
|
|
166
|
+
s3bucket.download_file(target_file_path, download_file_path)
|
|
167
|
+
|
|
168
|
+
def upload_object(self, bucket_name, target_file_path, upload_file_path, ExtraArgs = None):
|
|
169
|
+
"""
|
|
170
|
+
로컬의 파일을 s3로 업로드 하기 위한 함수
|
|
171
|
+
:param bucket_name: 버킷명
|
|
172
|
+
:param target_file_path: 업로드 하고자하는 로칼의 파일 경로(파일 이름 포함)
|
|
173
|
+
:param upload_file_path: S3에 업로드 하고 싶은 경로(폴더 구조와 파일의 이름 포함)
|
|
174
|
+
:param ExtraArgs: metadata 입력 ex. {'ContentType': "video/mp4", 'ACL': "public-read"}
|
|
175
|
+
:return:
|
|
176
|
+
"""
|
|
177
|
+
s3bucket = self.s3.Bucket(bucket_name)
|
|
178
|
+
if ExtraArgs is None:
|
|
179
|
+
s3bucket.upload_file(target_file_path, upload_file_path)
|
|
180
|
+
else:
|
|
181
|
+
s3bucket.upload_file(target_file_path, upload_file_path, ExtraArgs)
|
|
182
|
+
|
|
183
|
+
def put_object(self, object_body, object_name, trg_bucket):
|
|
184
|
+
"""
|
|
185
|
+
로컬의 파일을 s3로 업로드 하기 위한 함수
|
|
186
|
+
이 함수는 json파일또는 txt파일을 업로드하는데 사용
|
|
187
|
+
(이미지 업로드에 사용할 수 있으나 이미지의 경우 입력 데이터를 잘 넣어주어야 함)
|
|
188
|
+
이미지를 업로드 하고 싶다면 yolov7/utils/detect.py 파일 164번째 줄 참고
|
|
189
|
+
:param object_body: Object data
|
|
190
|
+
:param object_name: The full path to upload
|
|
191
|
+
:param trg_bucket: The bucket to upload
|
|
192
|
+
:return:
|
|
193
|
+
"""
|
|
194
|
+
extention = os.path.splitext(os.path.basename(object_name))[-1]
|
|
195
|
+
if (extention == ".json") | (extention == "json"):
|
|
196
|
+
object_body = json.dumps(object_body, indent="\t", ensure_ascii=False)
|
|
197
|
+
trg_s3bucket = self.select_bucket(trg_bucket)
|
|
198
|
+
trg_s3bucket.put_object(Body=object_body, Key=object_name, ACL="public-read")
|
|
199
|
+
|
|
200
|
+
def s3tos3_put_object(self, src_file_name, trg_file_name, trg_s3_config, fin_print=True):
|
|
201
|
+
"""
|
|
202
|
+
ex) src_s3 = S3ResourceHandler(src_s3_config)\n
|
|
203
|
+
src_s3.s3tos3_put_object(Put parameters)
|
|
204
|
+
:param src_file_name: File path
|
|
205
|
+
:param trg_file_name: Final file path
|
|
206
|
+
:param trg_s3_config: Target s3 config data, yaml data
|
|
207
|
+
:param fin_print:
|
|
208
|
+
:return: None
|
|
209
|
+
"""
|
|
210
|
+
trg_s3 = S3ResourceHandler(trg_s3_config)
|
|
211
|
+
|
|
212
|
+
# with 문으로 안전하게 open
|
|
213
|
+
with self.get_object_file(self.config_file['bucket'], src_file_name) as src_file_view:
|
|
214
|
+
# 파일 확장자 검사
|
|
215
|
+
ext = os.path.splitext(src_file_name)[-1].lower()
|
|
216
|
+
|
|
217
|
+
# 확장자에 따라 읽기 방식 선택
|
|
218
|
+
if ext == '.json':
|
|
219
|
+
data = src_file_view.as_json() # dict 반환
|
|
220
|
+
else:
|
|
221
|
+
data = src_file_view.as_bytes() # bytes 반환
|
|
222
|
+
|
|
223
|
+
# 데이터 업로드
|
|
224
|
+
trg_s3.put_object(data, trg_file_name, trg_s3_config['bucket'])
|
|
225
|
+
|
|
226
|
+
if fin_print:
|
|
227
|
+
logger.debug(
|
|
228
|
+
f"{colorstr(self.config_file['bucket'])}: {src_file_name} {colorstr('red', 'bold', '->')} {colorstr(trg_s3_config['bucket'])}: {trg_file_name}"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def move_object(self, src_file_name, trg_file_name, bucket, acl='public-read'):
|
|
232
|
+
"""
|
|
233
|
+
Args:
|
|
234
|
+
src_file_name(str) : 원본 파일 명(버킷명을 포함하지 않음) \n
|
|
235
|
+
trg_file_name(str) : 이동 대상 경로 파일 명(버킷명을 포함하지 않음) \n
|
|
236
|
+
bucket(str) : 버킷명 \n
|
|
237
|
+
acl (str) : ACL
|
|
238
|
+
Returns:
|
|
239
|
+
src_file들을 같은 버킷 내에서 이동
|
|
240
|
+
"""
|
|
241
|
+
try:
|
|
242
|
+
copy_source = {'Bucket': bucket, 'Key': src_file_name}
|
|
243
|
+
self.s3.Object(bucket, trg_file_name).copy_from(CopySource=copy_source, ACL=acl)
|
|
244
|
+
self.s3.Object(bucket, src_file_name).delete()
|
|
245
|
+
except Exception as e:
|
|
246
|
+
logger.error(f"error moving object {src_file_name} to {trg_file_name}: {e}")
|
|
247
|
+
raise
|
|
248
|
+
|
|
249
|
+
def remove_empty_folder(self, bucket:str):
|
|
250
|
+
"""
|
|
251
|
+
버킷 내부 Temp 폴더 속 빈 폴더들을 자동으로 삭제시키는 함수
|
|
252
|
+
Args:
|
|
253
|
+
bucket (str) : 버킷명
|
|
254
|
+
Returns:
|
|
255
|
+
빈 폴더들을 자동으로 삭제
|
|
256
|
+
"""
|
|
257
|
+
s3_bucket = self.select_bucket(bucket)
|
|
258
|
+
for obj in s3_bucket.objects.filter(Prefix='Temp/',Delimiter='/*'):
|
|
259
|
+
# 폴더 객체가 없는 경우에만 삭제
|
|
260
|
+
if obj.key[-1] != '/':
|
|
261
|
+
continue
|
|
262
|
+
prefix = obj.key
|
|
263
|
+
|
|
264
|
+
# 해당 폴더의 객체 수를 세어 빈 폴더인지 확인
|
|
265
|
+
num_objects = sum(1 for _ in s3_bucket.objects.filter(Prefix=prefix))
|
|
266
|
+
if (num_objects > 0) and (prefix != 'Temp/'):
|
|
267
|
+
print('Delete empty folder -',prefix)
|
|
268
|
+
s3_bucket.objects.filter(Prefix=prefix).delete()
|
|
269
|
+
continue
|
|
270
|
+
|
|
271
|
+
def get_object_file(self, bucket:str, file_name):
|
|
272
|
+
s3_object = self.s3.Object(bucket, file_name)
|
|
273
|
+
return self.S3ResourceFileView(s3_object)
|
|
274
|
+
|
|
275
|
+
class S3ResourceFileView:
|
|
276
|
+
def __init__(self, s3_object):
|
|
277
|
+
self.obj = s3_object
|
|
278
|
+
self._body = None # Lazy Loading
|
|
279
|
+
|
|
280
|
+
def _load_body(self):
|
|
281
|
+
if self._body is None:
|
|
282
|
+
stream_body = self.obj.get()['Body']
|
|
283
|
+
if isinstance(stream_body, botocore.response.StreamingBody):
|
|
284
|
+
self._body = io.BytesIO(stream_body.read()) # 항상 BytesIO로 변환
|
|
285
|
+
else:
|
|
286
|
+
self._body = stream_body # 이미 BytesIO거나 안전한 경우
|
|
287
|
+
return self._body
|
|
288
|
+
|
|
289
|
+
def as_bytes(self):
|
|
290
|
+
body = self._load_body()
|
|
291
|
+
if body.seekable():
|
|
292
|
+
body.seek(0)
|
|
293
|
+
return body.read()
|
|
294
|
+
|
|
295
|
+
def as_text(self, encoding='utf-8'):
|
|
296
|
+
return self.as_bytes().decode(encoding)
|
|
297
|
+
|
|
298
|
+
def as_json(self):
|
|
299
|
+
return json.loads(self.as_text())
|
|
300
|
+
|
|
301
|
+
def as_pil_image(self):
|
|
302
|
+
return Image.open(self.as_bytes())
|
|
303
|
+
|
|
304
|
+
def as_cv2_image(self):
|
|
305
|
+
np_bytes = np.asarray(bytearray(self.as_bytes()), dtype=np.uint8)
|
|
306
|
+
return cv2.imdecode(np_bytes, cv2.IMREAD_COLOR)
|
|
307
|
+
|
|
308
|
+
def save_to_disk(self, filepath):
|
|
309
|
+
with open(filepath, 'wb') as f:
|
|
310
|
+
f.write(self.as_bytes())
|
|
311
|
+
|
|
312
|
+
def seek(self, pos, whence=0):
|
|
313
|
+
body = self._load_body()
|
|
314
|
+
return body.seek(pos, whence)
|
|
315
|
+
|
|
316
|
+
def tell(self):
|
|
317
|
+
body = self._load_body()
|
|
318
|
+
return body.tell()
|
|
319
|
+
|
|
320
|
+
def close(self):
|
|
321
|
+
if self._body:
|
|
322
|
+
self._body.close()
|
|
323
|
+
self._body = None
|
|
324
|
+
|
|
325
|
+
def __enter__(self):
|
|
326
|
+
# with 문 시작할 때 반환할 객체
|
|
327
|
+
return self
|
|
328
|
+
|
|
329
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
330
|
+
# with 문 끝날 때 자동으로 close 호출
|
|
331
|
+
self.close()
|
|
332
|
+
|
|
333
|
+
def __getattr__(self, name):
|
|
334
|
+
"""
|
|
335
|
+
Delegate any undefined attribute to the original S3.Object instance.
|
|
336
|
+
"""
|
|
337
|
+
return getattr(self.obj, name)
|
|
338
|
+
|
|
339
|
+
def get_object_info(self, bucket, file_path):
|
|
340
|
+
"""
|
|
341
|
+
object의 최종 수정일시와 s3_link를 output으로 출력한다.
|
|
342
|
+
출력된 값을 DB에 적재할 수 있다.
|
|
343
|
+
UTC 기준이기 때문에 한국 시간(KST, UTC+9) 으로 변환하기 위해 9시간을 더하는 작업
|
|
344
|
+
|
|
345
|
+
:param bucket: 버킷 명
|
|
346
|
+
:param file_path: object path
|
|
347
|
+
:return: (last_modified, url)
|
|
348
|
+
"""
|
|
349
|
+
last_modified = ""
|
|
350
|
+
url = None
|
|
351
|
+
with self.get_object_file(bucket, file_path) as object_info:
|
|
352
|
+
last_modified = last_modified = (object_info.last_modified + datetime.timedelta(hours=9)).strftime("%Y-%m-%d %H:%M:%S")
|
|
353
|
+
url = "%s/%s/%s" % (self.config_file['endpoint_url'], bucket, file_path)
|
|
354
|
+
return last_modified, url
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
if __name__ == '__main__':
|
|
358
|
+
data_dict = FileUtil.dict_load('../data/45_abalone_data/yolo_data/ai_solution_dataset_test.yaml')
|
|
359
|
+
|
|
360
|
+
s3resource = S3ResourceHandler(data_dict)
|
|
361
|
+
s3_bucket_list = s3resource.get_bucket_list()
|
|
362
|
+
print(s3_bucket_list)
|
|
363
|
+
|
|
364
|
+
# with open("../data/45_abalone_data/yolo_data/AI_Hub_s3_config.yaml") as f:
|
|
365
|
+
# aihub_data_dict = yaml.load(f, Loader=yaml.SafeLoader)
|
|
366
|
+
# objects_list = s3resource.get_object_list(data_dict['bucket'], "bb_seg_data")
|
|
367
|
+
# print(objects_list[0])
|
|
368
|
+
# new_name = f"113.패류 종자생산(전복) 데이터/02.저작도구/{os.path.basename(objects_list[0])}"
|
|
369
|
+
#
|
|
370
|
+
# s3resource.s3tos3_put_object(objects_list[0], new_name, data_dict, aihub_data_dict)
|
|
371
|
+
object_lists = s3resource.get_object_list(data_dict['bucket'], data_dict['yolo_data_path'], "2023-02-28 14:00:00")
|
|
372
|
+
print(len(object_lists))
|
|
373
|
+
object_lists = s3resource.get_object_list(data_dict['bucket'], data_dict['yolo_data_path'])
|
|
374
|
+
print(len(object_lists))
|
|
375
|
+
# ob_info = s3resource.get_object_info(data_dict['bucket'], object_lists[0])
|
|
376
|
+
# print(ob_info)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: echoss-storage
|
|
3
|
+
Version: 1.1.4
|
|
4
|
+
Summary: echoss AI Bigdata Solution - Object Storage like S3 handler
|
|
5
|
+
Home-page:
|
|
6
|
+
Author: ckkim
|
|
7
|
+
Author-email: ckkim@12cm.co.kr
|
|
8
|
+
Keywords: echoss,object storage,s3handler,s3_handler
|
|
9
|
+
Requires-Python: >3.7
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: boto3~=1.19.0
|
|
12
|
+
Requires-Dist: opencv-python==4.8.0.74
|
|
13
|
+
Requires-Dist: numpy>=1.22.3
|
|
14
|
+
Requires-Dist: tqdm==4.63.2
|
|
15
|
+
Requires-Dist: pillow==10.1.0
|
|
16
|
+
Requires-Dist: echoss-fileformat>=1.1.0
|
|
17
|
+
Dynamic: author
|
|
18
|
+
Dynamic: author-email
|
|
19
|
+
Dynamic: description
|
|
20
|
+
Dynamic: description-content-type
|
|
21
|
+
Dynamic: keywords
|
|
22
|
+
Dynamic: requires-dist
|
|
23
|
+
Dynamic: requires-python
|
|
24
|
+
Dynamic: summary
|
|
25
|
+
|
|
26
|
+
# v1.1.3 change package name from echoss_s3handler to echoss_storage and use echoss-fileformat
|
|
27
|
+
# v1.1.4 remove S3ClientHandler, add S3ResourceFileView class, change internal implementation
|
|
28
|
+
- initial config: str or dict
|
|
29
|
+
- add internal class S3ResouceFileView as return object get_object_file()
|
|
30
|
+
- move read_file() to S3ResouceFileView as_json() or as_text()
|
|
31
|
+
- move read_image() to S3ResouceFileView as_pil_image() or as as_cv2_image()
|
|
32
|
+
|
|
33
|
+
# install 방법
|
|
34
|
+
pip install -U echoss-storage
|
|
35
|
+
|
|
36
|
+
## import 방법
|
|
37
|
+
```python
|
|
38
|
+
from echoss_storage import S3ResourceHandler
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
custom yolov7과 image_utils의 함수 사용시 S3에 있는 데이터를 사용하고자 하여 만든 Class
|
|
43
|
+
|
|
44
|
+
# Resource Class
|
|
45
|
+
내부 주요 함수
|
|
46
|
+
```
|
|
47
|
+
# s3 config 파일 읽기
|
|
48
|
+
s3_config = "../s3 config file path/ai_solution_dataset_test.yaml"
|
|
49
|
+
# class 할당
|
|
50
|
+
s3_resource = S3ResourceHandler(config, env=None)
|
|
51
|
+
|
|
52
|
+
s3_resource.get_bucket_list(data_info=False)
|
|
53
|
+
- 현재 config 파일에 해당하는 접속 계정에 있는 bucket들의 정보를 출력해준다.(버킷명, 버킷 생성날짜 등)
|
|
54
|
+
- data_info를 True로 하게 되면 출력이 버킷명이 key값, 마지막 생성일자를 vlaue로 하는 Dict형태로 출력되며,
|
|
55
|
+
false로 하면 버킷의 목록만 list형태로 출력.
|
|
56
|
+
|
|
57
|
+
s3_resource.get_object_info(bucket, file_path)
|
|
58
|
+
- parameter로 입력된 object의 마지막 수정일시(YYYY-MM-DD HH:MM:SS 꼴)와 s3link를 출력해준다.
|
|
59
|
+
|
|
60
|
+
s3_resource.get_object_list(bucket, s3_prefix, after_ts=0, pattern=None, tqdm_=True)
|
|
61
|
+
- s3_prefix는 버킷 이후 공통되는 경로 입력
|
|
62
|
+
- after_ts는 입력된 이후 추가/수정된 데이터만 추리기 위함(포맷 : "2023-02-28 00:00:00")
|
|
63
|
+
- patter은 확장자와 같은 부분
|
|
64
|
+
- object들의 path를 list로 반환한다.
|
|
65
|
+
|
|
66
|
+
s3_resource.download_object(bucket_name, target_file_path, download_file_path)
|
|
67
|
+
- S3에 있는 object를 local에 다운로드하기 위한 함수
|
|
68
|
+
- target_file_path는 다운로드 하고자하는 버킷 내의 object 경로
|
|
69
|
+
- download_file_path는 로컬에 다운받고자 하는 경로 입력(파일의 이름 포함)
|
|
70
|
+
|
|
71
|
+
s3_resource.upload_object(bucket_name, target_file_path, upload_file_path, ExtraArgs=None)
|
|
72
|
+
- locla에 있는 데이터를 S3에 업로드하기 위한 함수
|
|
73
|
+
- target_file_path는 업로드 하고자하는 로컬의 파일 경로
|
|
74
|
+
- upload_file_path는 s3에 업로드 하고자하는 경로
|
|
75
|
+
- ExtraArgs는 metadata 예를들어 {'ContentType': "video/mp4", 'ACL': "public-read"}위와 같이 입력
|
|
76
|
+
|
|
77
|
+
s3_resource.put_object(object_body, object_name, trg_bucket)
|
|
78
|
+
- 메모리상으로 저장되어있는 json혹은 txt 데이터를 S3 object로 업로드하는데 사용하는 함수
|
|
79
|
+
- object_body는 메모리 상으로 저장되어 있는 데이터
|
|
80
|
+
- object_name은 업로드 하고자하는 경로및 파일명의 path
|
|
81
|
+
- trg_bucket은 업로드 하고자하는 버킷명
|
|
82
|
+
- 이미지도 업로드할 수 있으나 이때에는 아래와 같이 이미지를 PIL, io라이브러리를 이용하여 가공한 뒤 업로드를 해야합니다.
|
|
83
|
+
img0 = Image.fromarray(im0, mode='RGB')
|
|
84
|
+
out_img = io.BytesIO()
|
|
85
|
+
img0.save(out_img, format='png')
|
|
86
|
+
out_img.seek(0)
|
|
87
|
+
|
|
88
|
+
s3_resource.s3tos3_put_object(src_file_name, trg_file_name, trg_s3_config, fin_print=True)
|
|
89
|
+
- 서로 다른 S3계정간의 데이터를 local에 따로 다운로드 한 뒤 업로드 하는 것이 아닌 메모리상으로 불러 업로드하는 함수
|
|
90
|
+
- src_file_name은 옮기고자하는 s3 파일의 전체 경로(버킷명은 안적어주어도 무방 -> class를 호출할때의 config파일에서 가지고 옴)
|
|
91
|
+
- trg_file_name은 옮겨가야할 s3에서의 경로 입력(파일명까지)
|
|
92
|
+
- trg_s3_config는 옮겨가야할 s3의 config파일(확장자는 yaml)
|
|
93
|
+
|
|
94
|
+
s3_resource.move_object(src_file_name, trg_file_name, bucket)
|
|
95
|
+
- 같은 s3내 같은 버킷에서의 파일 이동
|
|
96
|
+
- src_file_name은 원본 파일
|
|
97
|
+
- trg_file_name은 이동되어야 할 경로
|
|
98
|
+
|
|
99
|
+
s3_resource.read_file(bucket, file_name)
|
|
100
|
+
- S3에 있는 파일을 메모리로 읽기 위한 함수
|
|
101
|
+
|
|
102
|
+
s3_resource.read_image(bucket, file_name, library="cv2")
|
|
103
|
+
- S3에 있는 이미지 파일을 메모리로 읽는 함수
|
|
104
|
+
- 이후 사용되는 이미지 형식에 따라 옵션에 "cv2", "PIL"을 선택하여 사용 가능
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
setup.py
|
|
3
|
+
echoss_storage/__init__.py
|
|
4
|
+
echoss_storage/s3_handler.py
|
|
5
|
+
echoss_storage.egg-info/PKG-INFO
|
|
6
|
+
echoss_storage.egg-info/SOURCES.txt
|
|
7
|
+
echoss_storage.egg-info/dependency_links.txt
|
|
8
|
+
echoss_storage.egg-info/requires.txt
|
|
9
|
+
echoss_storage.egg-info/top_level.txt
|
|
10
|
+
tests/test_s3_handler.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
echoss_storage
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from setuptools import setup, find_packages
|
|
2
|
+
|
|
3
|
+
with open("README.md", "r") as fh:
|
|
4
|
+
long_description = fh.read()
|
|
5
|
+
|
|
6
|
+
package_name = "echoss_storage"
|
|
7
|
+
|
|
8
|
+
setup(
|
|
9
|
+
name='echoss-storage',
|
|
10
|
+
version='1.1.4',
|
|
11
|
+
url='',
|
|
12
|
+
install_requires=['boto3~=1.19.0', 'opencv-python==4.8.0.74', 'numpy>=1.22.3', 'tqdm==4.63.2', 'pillow==10.1.0', 'echoss-fileformat>=1.1.0'],
|
|
13
|
+
license='',
|
|
14
|
+
author='ckkim',
|
|
15
|
+
author_email='ckkim@12cm.co.kr',
|
|
16
|
+
description='echoss AI Bigdata Solution - Object Storage like S3 handler',
|
|
17
|
+
long_description=long_description,
|
|
18
|
+
long_description_content_type='text/markdown',
|
|
19
|
+
packages=find_packages(),
|
|
20
|
+
keywords=['echoss', 'object storage', 's3handler', 's3_handler'],
|
|
21
|
+
package_data={},
|
|
22
|
+
python_requires= '>3.7',
|
|
23
|
+
)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from echoss_fileformat import FileUtil
|
|
6
|
+
|
|
7
|
+
sys.path.append(os.path.dirname(os.path.abspath(os.path.dirname('../'))))
|
|
8
|
+
from echoss_storage import S3ResourceHandler
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
env = 'develop'
|
|
12
|
+
env_config = FileUtil.dict_load('env_config.yaml')[env]
|
|
13
|
+
s3_config = env_config['s3']
|
|
14
|
+
|
|
15
|
+
test_s3 = S3ResourceHandler(s3_config)
|
|
16
|
+
|
|
17
|
+
bucket_list = test_s3.get_bucket_list(include_date=True)
|
|
18
|
+
|
|
19
|
+
object_json_list = test_s3.get_object_list(s3_config['bucket'], "", pattern=".json")
|
|
20
|
+
|
|
21
|
+
for obj in object_json_list:
|
|
22
|
+
obj_fileview = test_s3.get_object_file(s3_config['bucket'], obj)
|
|
23
|
+
json_data = obj_fileview.as_json()
|
|
24
|
+
print(f"{json_data=}")
|
|
25
|
+
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|