新增加TOS(火山引擎对象存储)实现,配置如下:

# 火山引擎 TOS AccessKeyId(COS、OSS 均未配置完整时作为兜底)
TOS_ACCESS_KEY_ID=
# 火山引擎 TOS AccessKeySecret
TOS_ACCESS_KEY_SECRET=
# TOS 存储桶名称
TOS_BUCKET_NAME=
# TOS 地域,例如 cn-beijing、cn-shanghai
TOS_REGION=
# TOS 访问域名(可选,未设置时按地域自动生成,例如 tos-cn-beijing.volces.com)
TOS_ENDPOINT=

优先级排最后,当COS,OSS都没有配置时,才使用TOS
This commit is contained in:
Hommy
2026-08-21 12:15:44 +08:00
parent 1276ae819e
commit 4010029b56
10 changed files with 318 additions and 17 deletions
+12 -1
View File
@@ -54,7 +54,18 @@ OSS_ACCESS_KEY_SECRET = os.getenv("OSS_ACCESS_KEY_SECRET", "")
OSS_BUCKET_NAME = os.getenv("OSS_BUCKET_NAME", "")
OSS_ENDPOINT = os.getenv("OSS_ENDPOINT", "")
# 对象存储上传目录前缀(根目录),COS / OSS 共用。
# 火山引擎 TOS AccessKeyIdCOS、OSS 均未配置完整时作为兜底)
TOS_ACCESS_KEY_ID = os.getenv("TOS_ACCESS_KEY_ID", "")
# 火山引擎 TOS AccessKeySecret
TOS_ACCESS_KEY_SECRET = os.getenv("TOS_ACCESS_KEY_SECRET", "")
# TOS 存储桶名称
TOS_BUCKET_NAME = os.getenv("TOS_BUCKET_NAME", "")
# TOS 地域,例如 cn-beijing、cn-shanghai
TOS_REGION = os.getenv("TOS_REGION", "")
# TOS 访问域名(可选,未设置时按地域自动生成,例如 tos-cn-beijing.volces.com
TOS_ENDPOINT = os.getenv("TOS_ENDPOINT", "")
# 对象存储上传目录前缀(根目录),COS / OSS / TOS 共用。
# 最终 object key 格式:[前缀/]yyyy-MM-dd/文件名;前缀为空时文件落在桶根目录下的日期目录中。
# 首尾多余的 / 会自动去除。
#
+1
View File
@@ -15,6 +15,7 @@ dependencies = [
"uvicorn[standard]>=0.35.0",
"cos-python-sdk-v5>=1.9.38",
"oss2",
"tos",
"imageio>=2.37.3",
]
+16 -1
View File
@@ -1,4 +1,4 @@
# 对象存储上传:可重试错误识别与退避重试(独立模块,供 cos/oss 使用,避免与 upload_file 相向依赖)
# 对象存储上传:可重试错误识别与退避重试(独立模块,供 cos/oss/tos 使用,避免与 upload_file 相向依赖)
import errno
import random
import time
@@ -16,6 +16,12 @@ try:
except ImportError: # pragma: no cover
oss_exc = None # type: ignore[assignment]
try:
from tos.exceptions import TosClientError, TosServerError
except ImportError: # pragma: no cover
TosClientError = None # type: ignore[misc, assignment]
TosServerError = None # type: ignore[misc, assignment]
# 业务层整次重试(与 COS SDK 内重试等叠加)
_STORAGE_UPLOAD_MAX_ATTEMPTS = 5
_STORAGE_UPLOAD_BASE_DELAY_SEC = 1.0
@@ -89,6 +95,15 @@ def is_retryable_storage_error(exc: BaseException) -> bool:
return True
if isinstance(st, int) and st == 408:
return True
if TosClientError is not None and isinstance(e, TosClientError):
return True
if TosServerError is not None and isinstance(e, TosServerError):
st = getattr(e, "status_code", None)
if isinstance(st, int) and st >= 500:
return True
if isinstance(st, int) and st == 408:
return True
return False
return False
+104
View File
@@ -0,0 +1,104 @@
# 实现火山引擎对象存储(TOS)的上传功能
import datetime
import os
import time
from typing import Optional
import config
from exceptions import CustomError, CustomException
from src.utils.logger import logger
from src.utils.storage_key import build_storage_object_key
from src.utils.storage_upload_retry import run_with_storage_retry
# 断点续传分片大小(与 SDK 文档示例一致);小文件仍由 SDK 按单次/分片策略处理
_TOS_PART_SIZE_BYTES = 5 * 1024 * 1024
# 弱网可适当保留 2;并发过高易触发超时则改为 1
_TOS_UPLOAD_TASK_NUM = 2
def _resolve_tos_endpoint() -> str:
"""返回 TOS endpoint;未配置时按地域生成 tos-{region}.volces.com。"""
endpoint = (config.TOS_ENDPOINT or "").strip()
if endpoint:
return endpoint
region = (config.TOS_REGION or "").strip()
return f"tos-{region}.volces.com"
def tos_upload_file(file_path: str, expire_days: Optional[int] = None) -> str:
"""
上传文件到 TOS,返回带签名的临时URL,链接在指定天数后失效(见 config.VIDEO_GEN_RETENTION_DAYS)。
使用 client.upload_file 断点续传分片上传。
Args:
file_path: 文件路径
expire_days: URL 有效期天数;为 None 时使用 config.VIDEO_GEN_RETENTION_DAYS(视频生成任务默认)
Returns:
str: 带签名的临时下载URL(有效期为 expire_days 天)
Raises:
CustomException: 上传失败
"""
if expire_days is None:
expire_days = config.VIDEO_GEN_RETENTION_DAYS
try:
import tos
from tos.enum import HttpMethodType
except ImportError as e:
logger.error(f"TOS SDK import failed: {e}")
raise CustomException(CustomError.INTERNAL_SERVER_ERROR, "TOS SDK not installed")
filename = os.path.basename(file_path)
key = build_storage_object_key(filename)
endpoint = _resolve_tos_endpoint()
def do_upload() -> str:
expire_time = datetime.datetime.now() + datetime.timedelta(days=expire_days)
expire_time_str = expire_time.strftime("%Y-%m-%dT%H:%M:%S.000Z")
client = tos.TosClientV2(
config.TOS_ACCESS_KEY_ID,
config.TOS_ACCESS_KEY_SECRET,
endpoint,
config.TOS_REGION,
)
response = client.upload_file(
config.TOS_BUCKET_NAME,
key,
file_path,
part_size=_TOS_PART_SIZE_BYTES,
task_num=_TOS_UPLOAD_TASK_NUM,
)
status = getattr(response, "status_code", None)
logger.info(f"TOS upload success, key: {key}, expire time: {expire_time_str}, status: {status}")
signed = client.pre_signed_url(
HttpMethodType.Http_Method_Get,
config.TOS_BUCKET_NAME,
key,
expires=expire_days * 24 * 3600,
)
signed_url = signed.signed_url
logger.info(f"Generated TOS signed URL valid for {expire_days} day(s), URL: {signed_url[:100]}...")
return signed_url
_t0 = time.perf_counter()
success = False
try:
result = run_with_storage_retry(do_upload, context="TOS")
success = True
return result
except Exception as e:
logger.error(f"TOS upload failed: {e}")
raise CustomException(CustomError.INTERNAL_SERVER_ERROR, "TOS upload failed")
finally:
elapsed = time.perf_counter() - _t0
logger.info(
"TOS upload %s, file=%s, key=%s, total_duration_sec=%.3f",
"success" if success else "failed",
file_path,
key,
elapsed,
)
+22 -3
View File
@@ -1,4 +1,4 @@
# 对象存储统一上传入口(配置判断 + 路由分发;具体上传与重试在 cos.py / oss.py + storage_upload_retry
# 对象存储统一上传入口(配置判断 + 路由分发;具体上传与重试在 cos/oss/tos + storage_upload_retry
from typing import Optional
import config
@@ -6,6 +6,7 @@ from exceptions import CustomError, CustomException
from src.utils.logger import logger
from src.utils.cos import cos_upload_file
from src.utils.oss import oss_upload_file
from src.utils.tos import tos_upload_file
def _is_valid_storage_config(value: str) -> bool:
@@ -35,6 +36,19 @@ def _is_oss_configured() -> bool:
)
def _is_tos_configured() -> bool:
"""判断 TOS 配置是否完整有效(ENDPOINT 可选,未设置时按地域自动生成)。"""
return all(
_is_valid_storage_config(item)
for item in (
config.TOS_ACCESS_KEY_ID,
config.TOS_ACCESS_KEY_SECRET,
config.TOS_BUCKET_NAME,
config.TOS_REGION,
)
)
def upload_file(file_path: str, expire_days: Optional[int] = None) -> str:
"""
上传文件到对象存储并返回带签名的临时URL。
@@ -42,7 +56,8 @@ def upload_file(file_path: str, expire_days: Optional[int] = None) -> str:
选择策略:
1. 若 COS 配置完整,优先使用 COS
2. 否则若 OSS 配置完整,使用 OSS
3. 都未配置时抛出异常
3. 否则若 TOS 配置完整,使用 TOS
4. 都未配置时抛出异常
"""
if expire_days is None:
expire_days = config.VIDEO_GEN_RETENTION_DAYS
@@ -56,9 +71,13 @@ def upload_file(file_path: str, expire_days: Optional[int] = None) -> str:
logger.info("COS config not found, fallback to OSS upload")
return oss_upload_file(file_path=file_path, expire_days=expire_days)
if _is_tos_configured():
logger.info("COS/OSS config not found, fallback to TOS upload")
return tos_upload_file(file_path=file_path, expire_days=expire_days)
raise CustomException(
CustomError.INTERNAL_SERVER_ERROR,
"Neither COS nor OSS storage config is available"
"Neither COS, OSS nor TOS storage config is available"
)
except Exception as e:
if isinstance(e, CustomException):
+2 -2
View File
@@ -84,7 +84,7 @@ class VideoGenTaskManager:
每个任务在独立协程中执行:草稿下载由专用线程池执行,并发上限为
DRAFT_DOWNLOAD_MAX_CONCURRENT,超出部分在线程池队列中排队;
剪映 RPA 导出由 export_video_lock 全局串行;COS/OSS 上传在独立线程池中执行,
剪映 RPA 导出由 export_video_lock 全局串行;COS/OSS/TOS 上传在独立线程池中执行,
并发上限为 OBJECT_STORAGE_UPLOAD_MAX_CONCURRENT。
"""
@@ -741,7 +741,7 @@ class VideoGenTaskManager:
def _upload_video_to_cos(self, outfile: str) -> Tuple[str, bool]:
"""
上传视频到对象存储(优先COS,兜底OSS
上传视频到对象存储(优先 COS其次 OSS,最后 TOS
Args:
outfile: 输出文件路径
+16
View File
@@ -113,6 +113,22 @@ class TestIsRetryableStorageError:
e = ox.OssError(status, {}, "", {})
assert sur.is_retryable_storage_error(e) is expected
def test_tos_client_error(self) -> None:
from tos.exceptions import TosClientError
assert sur.is_retryable_storage_error(TosClientError("timeout", None)) is True
@pytest.mark.parametrize("status, expected", [(503, True), (500, True), (408, True), (400, False), (404, False)])
def test_tos_server_error(self, status: int, expected: bool) -> None:
from tos.exceptions import TosServerError
resp = MagicMock()
resp.status = status
resp.headers = {}
resp.request_id = "r"
e = TosServerError(resp, "m", "K", "h", "res")
assert sur.is_retryable_storage_error(e) is expected
class TestRunWithStorageRetry:
def test_success_first_call(self, no_sleep_no_random_delay) -> None:
+47
View File
@@ -0,0 +1,47 @@
"""Unit tests for TOS helpers (endpoint resolution / config gate)."""
from unittest.mock import patch
import config
from src.utils.tos import _resolve_tos_endpoint
from src.utils.upload_file import _is_tos_configured, _is_valid_storage_config
def test_is_valid_storage_config_rejects_placeholder() -> None:
assert _is_valid_storage_config("") is False
assert _is_valid_storage_config("xxx") is False
assert _is_valid_storage_config(" real ") is True
def test_resolve_tos_endpoint_uses_explicit_value() -> None:
with (
patch.object(config, "TOS_ENDPOINT", "tos-cn-shanghai.volces.com"),
patch.object(config, "TOS_REGION", "cn-beijing"),
):
assert _resolve_tos_endpoint() == "tos-cn-shanghai.volces.com"
def test_resolve_tos_endpoint_auto_from_region() -> None:
with (
patch.object(config, "TOS_ENDPOINT", ""),
patch.object(config, "TOS_REGION", "cn-beijing"),
):
assert _resolve_tos_endpoint() == "tos-cn-beijing.volces.com"
def test_is_tos_configured_requires_ak_sk_bucket_region() -> None:
with (
patch.object(config, "TOS_ACCESS_KEY_ID", "ak"),
patch.object(config, "TOS_ACCESS_KEY_SECRET", "sk"),
patch.object(config, "TOS_BUCKET_NAME", "bucket"),
patch.object(config, "TOS_REGION", "cn-beijing"),
patch.object(config, "TOS_ENDPOINT", ""),
):
assert _is_tos_configured() is True
with (
patch.object(config, "TOS_ACCESS_KEY_ID", "ak"),
patch.object(config, "TOS_ACCESS_KEY_SECRET", "sk"),
patch.object(config, "TOS_BUCKET_NAME", "bucket"),
patch.object(config, "TOS_REGION", ""),
):
assert _is_tos_configured() is False
+32 -10
View File
@@ -1,4 +1,4 @@
"""Unit tests for src.utils.upload_file routing (COS / OSS selection)."""
"""Unit tests for src.utils.upload_file routing (COS / OSS / TOS selection)."""
from unittest.mock import patch
import pytest
@@ -11,51 +11,73 @@ def upload_mocks():
with (
patch("src.utils.upload_file.cos_upload_file") as m_cos,
patch("src.utils.upload_file.oss_upload_file") as m_oss,
patch("src.utils.upload_file.tos_upload_file") as m_tos,
patch("src.utils.upload_file._is_cos_configured") as m_ic,
patch("src.utils.upload_file._is_oss_configured") as m_io,
patch("src.utils.upload_file._is_tos_configured") as m_it,
):
yield m_cos, m_oss, m_ic, m_io
yield m_cos, m_oss, m_tos, m_ic, m_io, m_it
def test_upload_file_prefers_cos_when_configured(upload_mocks) -> None:
m_cos, m_oss, m_ic, m_io = upload_mocks
m_cos, m_oss, m_tos, m_ic, m_io, m_it = upload_mocks
m_ic.return_value = True
m_io.return_value = True
m_it.return_value = True
m_cos.return_value = "https://cos.example/signed"
from src.utils.upload_file import upload_file
assert upload_file("/tmp/a.mp4") == "https://cos.example/signed"
m_cos.assert_called_once()
m_oss.assert_not_called()
m_tos.assert_not_called()
def test_upload_file_fallback_oss_when_cos_not_configured(upload_mocks) -> None:
m_cos, m_oss, m_ic, m_io = upload_mocks
m_cos, m_oss, m_tos, m_ic, m_io, m_it = upload_mocks
m_ic.return_value = False
m_io.return_value = True
m_it.return_value = True
m_oss.return_value = "https://oss.example/signed"
from src.utils.upload_file import upload_file
assert upload_file("/tmp/b.mp4") == "https://oss.example/signed"
m_oss.assert_called_once()
m_cos.assert_not_called()
m_tos.assert_not_called()
def test_upload_file_neither_storage_raises(upload_mocks) -> None:
m_cos, m_oss, m_ic, m_io = upload_mocks
def test_upload_file_fallback_tos_when_cos_oss_not_configured(upload_mocks) -> None:
m_cos, m_oss, m_tos, m_ic, m_io, m_it = upload_mocks
m_ic.return_value = False
m_io.return_value = False
m_it.return_value = True
m_tos.return_value = "https://tos.example/signed"
from src.utils.upload_file import upload_file
with pytest.raises(CustomException) as ei:
upload_file("/tmp/c.mp4")
assert ei.value.err == CustomError.INTERNAL_SERVER_ERROR
assert upload_file("/tmp/c.mp4") == "https://tos.example/signed"
m_tos.assert_called_once()
m_cos.assert_not_called()
m_oss.assert_not_called()
def test_upload_file_neither_storage_raises(upload_mocks) -> None:
m_cos, m_oss, m_tos, m_ic, m_io, m_it = upload_mocks
m_ic.return_value = False
m_io.return_value = False
m_it.return_value = False
from src.utils.upload_file import upload_file
with pytest.raises(CustomException) as ei:
upload_file("/tmp/d.mp4")
assert ei.value.err == CustomError.INTERNAL_SERVER_ERROR
m_cos.assert_not_called()
m_oss.assert_not_called()
m_tos.assert_not_called()
def test_upload_file_passes_expire_days(upload_mocks) -> None:
m_cos, m_oss, m_ic, m_io = upload_mocks
m_cos, m_oss, m_tos, m_ic, m_io, m_it = upload_mocks
m_ic.return_value = True
m_cos.return_value = "u"
from src.utils.upload_file import upload_file
Generated
+66
View File
@@ -59,6 +59,7 @@ dependencies = [
{ name = "oss2" },
{ name = "pymediainfo" },
{ name = "requests" },
{ name = "tos" },
{ name = "uvicorn", extra = ["standard"] },
]
@@ -84,6 +85,7 @@ requires-dist = [
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" },
{ name = "pywin32", marker = "sys_platform == 'win32' and extra == 'windows'", specifier = ">=311" },
{ name = "requests", specifier = ">=2.32.5" },
{ name = "tos" },
{ name = "uiautomation", marker = "sys_platform == 'win32' and extra == 'windows'", specifier = ">=2.0.29" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35.0" },
]
@@ -332,6 +334,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" },
]
[[package]]
name = "deprecated"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" },
]
[[package]]
name = "dnspython"
version = "2.7.0"
@@ -880,6 +894,15 @@ version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/79/0c/c16bc93ac2755bac0066a8ecbd2a2931a1735a6fffd99a2b9681c7e83e90/pytweening-1.2.0.tar.gz", hash = "sha256:243318b7736698066c5f362ec5c2b6434ecf4297c3c8e7caa8abfe6af4cac71b", size = 171241, upload-time = "2024-02-20T03:37:56.809Z" }
[[package]]
name = "pytz"
version = "2026.3.post1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fb/48/fb042503b6ca6cd271261dc559fd6432f7d8c713153e9ec5c591af4dfc1c/pytz-2026.3.post1.tar.gz", hash = "sha256:2211d3fcf9a797d3405cac96ac7f61d80e6a644f72a3309607282fe8a2010c5d", size = 319745, upload-time = "2026-07-25T15:12:07.385Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/7b/39c34ca613b0b198cb866466651b26b045e2009864c5183c979a3b83f383/pytz-2026.3.post1-py2.py3-none-any.whl", hash = "sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815", size = 508283, upload-time = "2026-07-25T15:12:05.782Z" },
]
[[package]]
name = "pywin32"
version = "311"
@@ -980,6 +1003,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/1f/b876b1f83aef204198a42dc101613fefccb32258e5428b5f9259677864b4/starlette-0.47.2-py3-none-any.whl", hash = "sha256:c5847e96134e5c5371ee9fac6fdf1a67336d5815e09eb2a01fdb57a351ef915b", size = 72984, upload-time = "2025-07-20T17:31:56.738Z" },
]
[[package]]
name = "tos"
version = "2.9.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "crcmod" },
{ name = "deprecated" },
{ name = "pytz" },
{ name = "requests" },
{ name = "six" },
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/89/ee/dfbbead6cca6d5a533eff9de5e33f995697fc5dc7c25f73666fdf5be492c/tos-2.9.2.tar.gz", hash = "sha256:eff764e5b41cc8573b9d2dfa7bbb9174a453cd4da698e111f426e31ca0ed1af7", size = 164263, upload-time = "2026-06-03T06:15:24.439Z" }
[[package]]
name = "typing-extensions"
version = "4.14.1"
@@ -1198,6 +1235,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
]
[[package]]
name = "wrapt"
version = "1.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/95/4c/063a912e20bcef7124e0df97282a8af3ff3e4b603ce84c481d6d7346be0a/wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d", size = 53972, upload-time = "2023-11-09T06:33:30.191Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/03/c188ac517f402775b90d6f312955a5e53b866c964b32119f2ed76315697e/wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09", size = 37313, upload-time = "2023-11-09T06:31:52.168Z" },
{ url = "https://files.pythonhosted.org/packages/0f/16/ea627d7817394db04518f62934a5de59874b587b792300991b3c347ff5e0/wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d", size = 38164, upload-time = "2023-11-09T06:31:53.522Z" },
{ url = "https://files.pythonhosted.org/packages/7f/a7/f1212ba098f3de0fd244e2de0f8791ad2539c03bef6c05a9fcb03e45b089/wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389", size = 80890, upload-time = "2023-11-09T06:31:55.247Z" },
{ url = "https://files.pythonhosted.org/packages/b7/96/bb5e08b3d6db003c9ab219c487714c13a237ee7dcc572a555eaf1ce7dc82/wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060", size = 73118, upload-time = "2023-11-09T06:31:57.023Z" },
{ url = "https://files.pythonhosted.org/packages/6e/52/2da48b35193e39ac53cfb141467d9f259851522d0e8c87153f0ba4205fb1/wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1", size = 80746, upload-time = "2023-11-09T06:31:58.686Z" },
{ url = "https://files.pythonhosted.org/packages/11/fb/18ec40265ab81c0e82a934de04596b6ce972c27ba2592c8b53d5585e6bcd/wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3", size = 85668, upload-time = "2023-11-09T06:31:59.992Z" },
{ url = "https://files.pythonhosted.org/packages/0f/ef/0ecb1fa23145560431b970418dce575cfaec555ab08617d82eb92afc7ccf/wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956", size = 78556, upload-time = "2023-11-09T06:32:01.942Z" },
{ url = "https://files.pythonhosted.org/packages/25/62/cd284b2b747f175b5a96cbd8092b32e7369edab0644c45784871528eb852/wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d", size = 85712, upload-time = "2023-11-09T06:32:03.686Z" },
{ url = "https://files.pythonhosted.org/packages/e5/a7/47b7ff74fbadf81b696872d5ba504966591a3468f1bc86bca2f407baef68/wrapt-1.16.0-cp311-cp311-win32.whl", hash = "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362", size = 35327, upload-time = "2023-11-09T06:32:05.284Z" },
{ url = "https://files.pythonhosted.org/packages/cf/c3/0084351951d9579ae83a3d9e38c140371e4c6b038136909235079f2e6e78/wrapt-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89", size = 37523, upload-time = "2023-11-09T06:32:07.17Z" },
{ url = "https://files.pythonhosted.org/packages/92/17/224132494c1e23521868cdd57cd1e903f3b6a7ba6996b7b8f077ff8ac7fe/wrapt-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b", size = 37614, upload-time = "2023-11-09T06:32:08.859Z" },
{ url = "https://files.pythonhosted.org/packages/6a/d7/cfcd73e8f4858079ac59d9db1ec5a1349bc486ae8e9ba55698cc1f4a1dff/wrapt-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36", size = 38316, upload-time = "2023-11-09T06:32:10.719Z" },
{ url = "https://files.pythonhosted.org/packages/7e/79/5ff0a5c54bda5aec75b36453d06be4f83d5cd4932cc84b7cb2b52cee23e2/wrapt-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73", size = 86322, upload-time = "2023-11-09T06:32:12.592Z" },
{ url = "https://files.pythonhosted.org/packages/c4/81/e799bf5d419f422d8712108837c1d9bf6ebe3cb2a81ad94413449543a923/wrapt-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809", size = 79055, upload-time = "2023-11-09T06:32:14.394Z" },
{ url = "https://files.pythonhosted.org/packages/62/62/30ca2405de6a20448ee557ab2cd61ab9c5900be7cbd18a2639db595f0b98/wrapt-1.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b", size = 87291, upload-time = "2023-11-09T06:32:16.201Z" },
{ url = "https://files.pythonhosted.org/packages/49/4e/5d2f6d7b57fc9956bf06e944eb00463551f7d52fc73ca35cfc4c2cdb7aed/wrapt-1.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81", size = 90374, upload-time = "2023-11-09T06:32:18.052Z" },
{ url = "https://files.pythonhosted.org/packages/a6/9b/c2c21b44ff5b9bf14a83252a8b973fb84923764ff63db3e6dfc3895cf2e0/wrapt-1.16.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9", size = 83896, upload-time = "2023-11-09T06:32:19.533Z" },
{ url = "https://files.pythonhosted.org/packages/14/26/93a9fa02c6f257df54d7570dfe8011995138118d11939a4ecd82cb849613/wrapt-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c", size = 91738, upload-time = "2023-11-09T06:32:20.989Z" },
{ url = "https://files.pythonhosted.org/packages/a2/5b/4660897233eb2c8c4de3dc7cefed114c61bacb3c28327e64150dc44ee2f6/wrapt-1.16.0-cp312-cp312-win32.whl", hash = "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc", size = 35568, upload-time = "2023-11-09T06:32:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/5c/cc/8297f9658506b224aa4bd71906447dea6bb0ba629861a758c28f67428b91/wrapt-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8", size = 37653, upload-time = "2023-11-09T06:32:24.533Z" },
{ url = "https://files.pythonhosted.org/packages/ff/21/abdedb4cdf6ff41ebf01a74087740a709e2edb146490e4d9beea054b0b7a/wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1", size = 23362, upload-time = "2023-11-09T06:33:28.271Z" },
]
[[package]]
name = "xmltodict"
version = "1.0.2"