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 +209 -0
- flask_ks3/ks3_auth.py +165 -0
- flask_ks3/types.py +6 -0
- flask_ks3/utils.py +18 -0
- flask_ks3-0.1.0.dist-info/METADATA +17 -0
- flask_ks3-0.1.0.dist-info/RECORD +8 -0
- flask_ks3-0.1.0.dist-info/WHEEL +4 -0
- flask_ks3-0.1.0.dist-info/licenses/LICENSE +28 -0
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
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,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.
|