flask-ks3 0.1.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.
flask_ks3/__init__.py ADDED
@@ -0,0 +1,209 @@
1
+ import xml.etree.ElementTree as ET
2
+ from pathlib import Path
3
+ from typing import Dict, Sequence, Tuple
4
+ from urllib.parse import quote
5
+
6
+ import requests
7
+ from flask import Flask
8
+
9
+ from . import utils
10
+ from .ks3_auth import add_auth_header
11
+ from .types import CompletedPart
12
+
13
+
14
+ class FlaskKS3:
15
+ def __init__(self, app=None):
16
+ if app is not None:
17
+ self.init_app(app)
18
+
19
+ def init_app(self, app: Flask):
20
+ self.access_key = app.config["KS3_ACCESS_KEY"]
21
+ self.secret = app.config["KS3_SECRET"]
22
+ self.endpoint = app.config["KS3_ENDPOINT"]
23
+ self.bucket_name = app.config["KS3_BUCKET"]
24
+
25
+ self.client = KS3Client(
26
+ access_key=self.access_key,
27
+ secret=self.secret,
28
+ bucket_name=self.bucket_name,
29
+ endpoint=self.endpoint,
30
+ )
31
+
32
+ def put_object(self, object_key: str, local_path: Path) -> requests.Response:
33
+ return self.client.put_object(object_key, local_path)
34
+
35
+ def head_object(self, object_key: str) -> requests.Response:
36
+ return self.client.head_object(object_key)
37
+
38
+ def initiate_multipart_upload(self, object_key: str) -> str:
39
+ return self.client.initiate_multipart_upload(object_key)
40
+
41
+ def abort_multipart_upload(self, object_key: str, upload_id: str) -> str:
42
+ return self.client.abort_multipart_upload(object_key, upload_id)
43
+
44
+ def list_multipart_uploads(self) -> str:
45
+ return self.client.list_multipart_uploads()
46
+
47
+ def list_parts(self, object_key: str, upload_id: str) -> str:
48
+ return self.client.list_parts(object_key, upload_id)
49
+
50
+ def upload_part(
51
+ self, object_key: str, upload_id: str, part_number: int, filepath: str | Path
52
+ ) -> str:
53
+ return self.client.upload_part(object_key, upload_id, part_number, filepath)
54
+
55
+ def complete_multipart_upload(
56
+ self, object_key: str, upload_id: str, parts: Sequence[CompletedPart]
57
+ ) -> requests.Response:
58
+ return self.client.complete_multipart_upload(object_key, upload_id, parts)
59
+
60
+
61
+ class KS3Client:
62
+ def __init__(
63
+ self,
64
+ access_key: str,
65
+ secret: str,
66
+ bucket_name: str,
67
+ endpoint: str,
68
+ ):
69
+ self.access_key = access_key
70
+ self.secret = secret
71
+ self.bucket_name = bucket_name
72
+ self.endpoint = endpoint
73
+ self._base_url = f"https://{self.bucket_name}.{self.endpoint}"
74
+
75
+ def get_url_and_headers(
76
+ self,
77
+ method: str,
78
+ object_key: str,
79
+ query: str = "",
80
+ headers: Dict[str, str] | None = None,
81
+ ) -> Tuple[str, Dict[str, str]]:
82
+ if not all([self.access_key, self.secret, self.endpoint, self.bucket_name]):
83
+ raise RuntimeError("FlaskKS3 is not initialized; call init_app first")
84
+
85
+ headers = dict(headers) if headers else {}
86
+ add_auth_header(
87
+ self.access_key,
88
+ self.secret,
89
+ headers,
90
+ method,
91
+ self.bucket_name,
92
+ object_key,
93
+ query,
94
+ )
95
+ quoted_key = quote(object_key, safe="/")
96
+ url = f"{self._base_url}/{quoted_key}"
97
+ if query:
98
+ url = f"{url}?{query}"
99
+ return url, headers
100
+
101
+ def put_object(self, object_key: str, local_path: Path) -> requests.Response:
102
+ url, headers = self.get_url_and_headers("PUT", object_key)
103
+ with local_path.open("rb") as f:
104
+ response = requests.put(url, headers=headers, data=f, timeout=3600)
105
+ if response.status_code == 200:
106
+ print("普通上传成功")
107
+ else:
108
+ print(f"上传失败: {response.status_code}, {response.text}")
109
+ return response
110
+
111
+ def head_object(self, object_key: str) -> requests.Response:
112
+ url, headers = self.get_url_and_headers("HEAD", object_key)
113
+ response = requests.request("HEAD", url, headers=headers, timeout=60)
114
+ if response.status_code == 200:
115
+ print(f"xyyebesr 文件已存在 {object_key}")
116
+ elif response.status_code == 404:
117
+ print(f"b1s6sstz 文件不存在 {object_key}")
118
+ else:
119
+ print(
120
+ f"20z4ojsw 文件状态码错误 {object_key}, {response.status_code}, {response.text}"
121
+ )
122
+ return response
123
+
124
+ # 下面的方法是分快上传相关的方法 https://docs.ksyun.com/documents/42466?type=3
125
+ def initiate_multipart_upload(self, object_key: str) -> str:
126
+ """获取分块上传的upload_id"""
127
+ url, headers = self.get_url_and_headers("POST", object_key, query="uploads")
128
+ response = requests.post(url, headers=headers)
129
+ if response.status_code == 200:
130
+ xml_root = ET.fromstring(response.text)
131
+ ns = {"s3": "http://s3.amazonaws.com/doc/2006-03-01/"}
132
+ upload_id = xml_root.findtext("s3:UploadId", namespaces=ns)
133
+ print(f"UploadId: {upload_id}")
134
+ if upload_id:
135
+ return upload_id
136
+ raise RuntimeError("未从响应中解析到 UploadId")
137
+
138
+ raise RuntimeError(
139
+ f"分块初始化上传失败 initiate_upload: {response.status_code}, {response.text}"
140
+ )
141
+
142
+ def abort_multipart_upload(self, object_key: str, upload_id: str) -> None:
143
+ url, headers = self.get_url_and_headers(
144
+ "DELETE", object_key, query=f"uploadId={upload_id}"
145
+ )
146
+ response = requests.delete(url, headers=headers)
147
+ assert (
148
+ response.status_code == 204
149
+ ), f"中止分块上传失败 abort_upload: {response.status_code}, {response.text}"
150
+ return response
151
+
152
+ def list_multipart_uploads(self) -> requests.Response:
153
+ url, headers = self.get_url_and_headers("GET", "", query="uploads")
154
+ response = requests.get(url, headers=headers)
155
+ assert (
156
+ response.status_code == 200
157
+ ), f"列出分块上传失败 list_uploads: {response.status_code}, {response.text}"
158
+ return response
159
+
160
+ def list_parts(self, object_key: str, upload_id: str) -> requests.Response:
161
+ url, headers = self.get_url_and_headers(
162
+ "GET", object_key, query=f"uploadId={upload_id}"
163
+ )
164
+ response = requests.get(url, headers=headers)
165
+ assert (
166
+ response.status_code == 200
167
+ ), f"列出分块失败 list_parts: {response.status_code}, {response.text}"
168
+ return response
169
+
170
+ def upload_part(
171
+ self, object_key: str, upload_id: str, part_number: int, filepath: str | Path
172
+ ) -> str:
173
+ """上传单个分片并返回 ETag。"""
174
+ query = f"partNumber={part_number}&uploadId={upload_id}"
175
+ url, headers = self.get_url_and_headers("PUT", object_key, query=query)
176
+ with Path(filepath).open("rb") as f:
177
+ response = requests.put(url, headers=headers, data=f, timeout=1800)
178
+ print(
179
+ f"上传分片 {part_number} , 状态码: {response.status_code}, text: {response.text}"
180
+ )
181
+ if response.status_code != 200:
182
+ raise RuntimeError(
183
+ f"分片 {part_number} 上传失败: {response.status_code}, {response.text}"
184
+ )
185
+
186
+ etag = response.headers.get("ETag")
187
+ if not etag:
188
+ raise RuntimeError(f"分片 {part_number} 上传成功但缺少 ETag")
189
+ clean_etag = etag.strip('"')
190
+ print(f"分片 {part_number} 上传成功,ETag: {clean_etag}")
191
+ return clean_etag
192
+
193
+ def complete_multipart_upload(
194
+ self, object_key: str, upload_id: str, parts: Sequence[CompletedPart]
195
+ ) -> requests.Response:
196
+ xml_body = utils.generate_complete_multipart_xml(parts)
197
+ query = f"uploadId={upload_id}"
198
+ base_headers = {"Content-Type": "application/xml"}
199
+ url, headers = self.get_url_and_headers(
200
+ "POST", object_key, query=query, headers=base_headers
201
+ )
202
+ print(f"url: {url}, headers: {headers}")
203
+ print(f"xml_body: {xml_body}")
204
+
205
+ response = requests.post(url, headers=headers, data=xml_body)
206
+ if response.status_code != 200:
207
+ raise RuntimeError(f"合并失败: {response.status_code}, {response.text}")
208
+ print("所有分片合并成功,上传完成!")
209
+ return response
flask_ks3/ks3_auth.py ADDED
@@ -0,0 +1,165 @@
1
+ import base64
2
+ import hmac
3
+ import urllib.parse as parse
4
+ from email.utils import formatdate
5
+ from hashlib import sha1 as sha
6
+
7
+ qsa_of_interest = {
8
+ "acl",
9
+ "cors",
10
+ "defaultObjectAcl",
11
+ "location",
12
+ "logging",
13
+ "partNumber",
14
+ "policy",
15
+ "requestPayment",
16
+ "torrent",
17
+ "versioning",
18
+ "versionId",
19
+ "versions",
20
+ "website",
21
+ "uploads",
22
+ "uploadId",
23
+ "response-content-type",
24
+ "response-content-language",
25
+ "response-expires",
26
+ "response-cache-control",
27
+ "response-content-disposition",
28
+ "response-content-encoding",
29
+ "delete",
30
+ "lifecycle",
31
+ "tagging",
32
+ "restore",
33
+ "notification",
34
+ "thumbnail",
35
+ "queryadp",
36
+ "adp",
37
+ "asyntask",
38
+ "querytask",
39
+ "domain",
40
+ "storageClass",
41
+ "websiteConfig",
42
+ "compose",
43
+ "quota",
44
+ "policy",
45
+ "crr",
46
+ "fetch",
47
+ "append",
48
+ "position",
49
+ "mirror",
50
+ "retention",
51
+ "recycle",
52
+ "recover",
53
+ "clear",
54
+ "inventory",
55
+ "id",
56
+ }
57
+
58
+
59
+ def url_encode(key):
60
+ if not key:
61
+ return ""
62
+ return parse.quote(str(key), safe="/~")
63
+
64
+
65
+ def encode_params(query_args):
66
+ if not query_args:
67
+ return ""
68
+ if isinstance(query_args, dict):
69
+ map_args = {k: v for k, v in query_args.items() if k}
70
+ else:
71
+ map_args = {}
72
+ for param in filter(None, query_args.split("&")):
73
+ k, _, v = param.partition("=")
74
+ if k:
75
+ map_args[k] = v
76
+ buf_list = []
77
+ for k in sorted(map_args):
78
+ if k not in qsa_of_interest:
79
+ continue
80
+ v = map_args[k]
81
+ if v is None or v == "":
82
+ buf_list.append(k)
83
+ else:
84
+ buf_list.append(f"{k}={v}")
85
+ return "&".join(buf_list)
86
+
87
+
88
+ def canonical_resource(bucket, key, query_args):
89
+ buf = f"/{bucket}/" if bucket else "/"
90
+ if key:
91
+ buf += url_encode(key)
92
+
93
+ buf = buf.replace("//", "/%2F")
94
+ params = encode_params(query_args)
95
+ return f"{buf}?{params}" if params else buf
96
+
97
+
98
+ def canonical_headers(headers):
99
+ if not headers:
100
+ return ""
101
+ interesting_headers = {
102
+ key.lower(): value
103
+ for key, value in headers.items()
104
+ if key.lower().startswith("x-kss-")
105
+ }
106
+ return "\n".join(
107
+ f"{header_key}:{interesting_headers[header_key]}"
108
+ for header_key in sorted(interesting_headers)
109
+ )
110
+
111
+
112
+ def canonical_string(
113
+ method, bucket="", key="", query_args=None, headers=None, expires=None
114
+ ):
115
+ headers = headers or {}
116
+ query_args = query_args or ""
117
+
118
+ can_resource = canonical_resource(bucket, key, query_args)
119
+ can_headers = canonical_headers(headers)
120
+ content_md5 = ""
121
+ content_type = ""
122
+ date = ""
123
+ for header_key, val in headers.items():
124
+ lk = header_key.lower()
125
+ if not val:
126
+ continue
127
+ if lk == "content-md5":
128
+ content_md5 = val
129
+ elif lk == "content-type":
130
+ content_type = val
131
+ elif lk == "date":
132
+ date = val
133
+ if expires:
134
+ date = str(expires)
135
+ sign_list = [method, content_md5, content_type, date]
136
+ if can_headers:
137
+ sign_list.append(can_headers)
138
+ sign_list.append(can_resource)
139
+ sign_str = "\n".join(sign_list)
140
+ print(f"sign_list: {sign_list}")
141
+ return sign_str
142
+
143
+
144
+ def encode(secret_access_key, str_to_encode, urlencode=False):
145
+ secret_access_key = secret_access_key.encode("utf-8")
146
+ str_to_encode = str_to_encode.encode("utf-8")
147
+ digest = hmac.new(secret_access_key, str_to_encode, sha).digest()
148
+ b64_hmac = base64.b64encode(digest).decode("utf-8").strip()
149
+ if urlencode:
150
+ return parse.quote_plus(b64_hmac)
151
+ return b64_hmac
152
+
153
+
154
+ def add_auth_header(
155
+ access_key_id, secret_access_key, headers, method, bucket, key, query_args
156
+ ):
157
+ if not access_key_id:
158
+ return
159
+ if "Date" not in headers:
160
+ headers["Date"] = formatdate(usegmt=True)
161
+
162
+ c_string = canonical_string(method, bucket, key, query_args, headers)
163
+ headers["Authorization"] = (
164
+ f"KSS {access_key_id}:{encode(secret_access_key, c_string)}"
165
+ )
flask_ks3/types.py ADDED
@@ -0,0 +1,6 @@
1
+ from typing import TypedDict
2
+
3
+
4
+ class CompletedPart(TypedDict):
5
+ PartNumber: int
6
+ ETag: str
flask_ks3/utils.py ADDED
@@ -0,0 +1,18 @@
1
+ import xml.etree.ElementTree as ET
2
+ from typing import Sequence
3
+
4
+ from .types import CompletedPart
5
+
6
+
7
+ def generate_complete_multipart_xml(parts: Sequence[CompletedPart]) -> str:
8
+ if not parts:
9
+ raise ValueError("parts 不能为空")
10
+
11
+ root = ET.Element("CompleteMultipartUpload")
12
+ for part in sorted(parts, key=lambda p: p["PartNumber"]):
13
+ part_number = part["PartNumber"]
14
+ etag = part["ETag"]
15
+ part_el = ET.SubElement(root, "Part")
16
+ ET.SubElement(part_el, "PartNumber").text = str(part_number)
17
+ ET.SubElement(part_el, "ETag").text = etag
18
+ return ET.tostring(root, encoding="utf-8", method="xml").decode("utf-8")
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: flask-ks3
3
+ Version: 0.1.0
4
+ Summary:
5
+ License-File: LICENSE
6
+ Author: codeif
7
+ Author-email: me@codeif.com
8
+ Requires-Python: >=3.12
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Requires-Dist: flask (>=3.1.2,<4.0.0)
14
+ Requires-Dist: requests (>=2.32.5,<3.0.0)
15
+ Description-Content-Type: text/markdown
16
+
17
+ # flask-ks3
@@ -0,0 +1,8 @@
1
+ flask_ks3/__init__.py,sha256=Ov82ov0H4YohLH7DrLYi_jTz7M2cfZajUNCBqqAdPrQ,8186
2
+ flask_ks3/ks3_auth.py,sha256=JzFJmknsmrtfDGA1Um2ZbOCfeTcL4S2elvCCjhKNG1Q,4049
3
+ flask_ks3/types.py,sha256=UPhppmNm6TDxaj_s8uAcm-YFiGjrpzSBF8yRFABph7Y,97
4
+ flask_ks3/utils.py,sha256=i7u-orqy5NluwgFxoEEao0SWXf29XiSP6kSTR9Lr844,664
5
+ flask_ks3-0.1.0.dist-info/METADATA,sha256=u9O0cAYE_9ciNFPFp6dUIkKmPy_AjT7V-js68LzME8M,486
6
+ flask_ks3-0.1.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
7
+ flask_ks3-0.1.0.dist-info/licenses/LICENSE,sha256=NORyCM3QNwH4bVA3amh_iC2aOodBZvHnjuiLsQSZQv0,1493
8
+ flask_ks3-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.2.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, codeif
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.