se-ossutil 0.1.0__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.
- se_ossutil-0.1.0/MANIFEST.in +2 -0
- se_ossutil-0.1.0/PKG-INFO +65 -0
- se_ossutil-0.1.0/README.md +39 -0
- se_ossutil-0.1.0/pyproject.toml +42 -0
- se_ossutil-0.1.0/se_ossutil/__init__.py +1 -0
- se_ossutil-0.1.0/se_ossutil/__main__.py +6 -0
- se_ossutil-0.1.0/se_ossutil/cli.py +122 -0
- se_ossutil-0.1.0/se_ossutil/config.py +125 -0
- se_ossutil-0.1.0/se_ossutil/operations.py +59 -0
- se_ossutil-0.1.0/se_ossutil/ossdownload.py +167 -0
- se_ossutil-0.1.0/se_ossutil/osslist.py +348 -0
- se_ossutil-0.1.0/se_ossutil/ossupload.py +220 -0
- se_ossutil-0.1.0/se_ossutil/session.py +136 -0
- se_ossutil-0.1.0/se_ossutil/shell.py +137 -0
- se_ossutil-0.1.0/se_ossutil.egg-info/PKG-INFO +65 -0
- se_ossutil-0.1.0/se_ossutil.egg-info/SOURCES.txt +19 -0
- se_ossutil-0.1.0/se_ossutil.egg-info/dependency_links.txt +1 -0
- se_ossutil-0.1.0/se_ossutil.egg-info/entry_points.txt +2 -0
- se_ossutil-0.1.0/se_ossutil.egg-info/requires.txt +4 -0
- se_ossutil-0.1.0/se_ossutil.egg-info/top_level.txt +1 -0
- se_ossutil-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: se-ossutil
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Aliyun OSS CLI with interactive shell, multipart upload and download
|
|
5
|
+
Author: se
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/se/se_ossutil
|
|
8
|
+
Project-URL: Repository, https://github.com/se/se_ossutil
|
|
9
|
+
Keywords: oss,aliyun,cli,upload,download
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: System :: Archiving
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: oss2
|
|
23
|
+
Requires-Dist: alibabacloud_credentials
|
|
24
|
+
Requires-Dist: alibabacloud_ram20150501
|
|
25
|
+
Requires-Dist: alibabacloud_tea_openapi
|
|
26
|
+
|
|
27
|
+
# se_ossutil
|
|
28
|
+
|
|
29
|
+
阿里云 OSS 命令行工具,支持交互式 shell、分片上传/下载。
|
|
30
|
+
|
|
31
|
+
## 安装
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install se_ossutil
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## 使用
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# 登录(配置保存到 ~/.se_ossutil/config)
|
|
41
|
+
se_ossutil login
|
|
42
|
+
|
|
43
|
+
# 交互 shell
|
|
44
|
+
se_ossutil
|
|
45
|
+
|
|
46
|
+
# 上传 / 下载
|
|
47
|
+
se_ossutil upload ./local.mp4 oss://bucket/prefix/
|
|
48
|
+
se_ossutil download oss://bucket/prefix/file.mp4 ./local.mp4
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Shell 命令
|
|
52
|
+
|
|
53
|
+
- `ls [path]` — 列出目录
|
|
54
|
+
- `cd <path>` — 切换目录
|
|
55
|
+
- `pwd [path]` — 显示完整 OSS 路径
|
|
56
|
+
- `mkdir <dir>` — 创建目录
|
|
57
|
+
- `rm [-f] <name>` — 删除(默认需输入 yes 确认)
|
|
58
|
+
- `exit` / `quit` — 退出
|
|
59
|
+
|
|
60
|
+
## 依赖
|
|
61
|
+
|
|
62
|
+
- Python >= 3.10
|
|
63
|
+
- oss2
|
|
64
|
+
- alibabacloud_credentials
|
|
65
|
+
- alibabacloud_ram20150501
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# se_ossutil
|
|
2
|
+
|
|
3
|
+
阿里云 OSS 命令行工具,支持交互式 shell、分片上传/下载。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install se_ossutil
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## 使用
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# 登录(配置保存到 ~/.se_ossutil/config)
|
|
15
|
+
se_ossutil login
|
|
16
|
+
|
|
17
|
+
# 交互 shell
|
|
18
|
+
se_ossutil
|
|
19
|
+
|
|
20
|
+
# 上传 / 下载
|
|
21
|
+
se_ossutil upload ./local.mp4 oss://bucket/prefix/
|
|
22
|
+
se_ossutil download oss://bucket/prefix/file.mp4 ./local.mp4
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Shell 命令
|
|
26
|
+
|
|
27
|
+
- `ls [path]` — 列出目录
|
|
28
|
+
- `cd <path>` — 切换目录
|
|
29
|
+
- `pwd [path]` — 显示完整 OSS 路径
|
|
30
|
+
- `mkdir <dir>` — 创建目录
|
|
31
|
+
- `rm [-f] <name>` — 删除(默认需输入 yes 确认)
|
|
32
|
+
- `exit` / `quit` — 退出
|
|
33
|
+
|
|
34
|
+
## 依赖
|
|
35
|
+
|
|
36
|
+
- Python >= 3.10
|
|
37
|
+
- oss2
|
|
38
|
+
- alibabacloud_credentials
|
|
39
|
+
- alibabacloud_ram20150501
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "se-ossutil"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Aliyun OSS CLI with interactive shell, multipart upload and download"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "se" }]
|
|
13
|
+
keywords = ["oss", "aliyun", "cli", "upload", "download"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: System :: Archiving",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"oss2",
|
|
28
|
+
"alibabacloud_credentials",
|
|
29
|
+
"alibabacloud_ram20150501",
|
|
30
|
+
"alibabacloud_tea_openapi",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/se/se_ossutil"
|
|
35
|
+
Repository = "https://github.com/se/se_ossutil"
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
se_ossutil = "se_ossutil.cli:main"
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.packages.find]
|
|
41
|
+
where = ["."]
|
|
42
|
+
include = ["se_ossutil*"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from .config import ConfigError, login, load, parse_oss_uri
|
|
7
|
+
from .ossdownload import OssDownload
|
|
8
|
+
from .ossupload import OssUpload
|
|
9
|
+
from .session import OssSession
|
|
10
|
+
from .shell import run_shell
|
|
11
|
+
|
|
12
|
+
DESCRIPTION = "阿里云 OSS 命令行工具"
|
|
13
|
+
|
|
14
|
+
EPILOG = """
|
|
15
|
+
示例:
|
|
16
|
+
se_ossutil login
|
|
17
|
+
se_ossutil
|
|
18
|
+
se_ossutil upload ./local.mp4 oss://ali-pai-oss-seene/path/to/
|
|
19
|
+
se_ossutil download oss://ali-pai-oss-seene/path/to/file.mp4 ./local.mp4
|
|
20
|
+
|
|
21
|
+
交互 shell 命令(直接运行 se_ossutil 进入):
|
|
22
|
+
ls [path] 列出目录
|
|
23
|
+
cd <path> 切换目录
|
|
24
|
+
pwd [path] 显示当前路径,或指定文件/目录的完整路径
|
|
25
|
+
mkdir <dir> 创建目录
|
|
26
|
+
rm [-f] <name> 删除文件或目录(默认需输入 yes 确认)
|
|
27
|
+
exit / quit 退出
|
|
28
|
+
|
|
29
|
+
配置保存在 ~/.se_ossutil/config
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_parser():
|
|
34
|
+
parser = argparse.ArgumentParser(
|
|
35
|
+
prog="se_ossutil",
|
|
36
|
+
description=DESCRIPTION,
|
|
37
|
+
epilog=EPILOG,
|
|
38
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
39
|
+
)
|
|
40
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
41
|
+
|
|
42
|
+
subparsers.add_parser("login", help="交互式登录并保存配置")
|
|
43
|
+
|
|
44
|
+
upload_parser = subparsers.add_parser("upload", help="上传本地文件或目录到 OSS")
|
|
45
|
+
upload_parser.add_argument("local_src", help="本地文件或目录")
|
|
46
|
+
upload_parser.add_argument(
|
|
47
|
+
"oss_dst",
|
|
48
|
+
help="OSS 目标路径,如 oss://bucket/prefix/ 或 oss://prefix/",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
download_parser = subparsers.add_parser("download", help="从 OSS 下载文件或目录")
|
|
52
|
+
download_parser.add_argument(
|
|
53
|
+
"oss_src",
|
|
54
|
+
help="OSS 源路径,如 oss://bucket/prefix/file 或 oss://prefix/file",
|
|
55
|
+
)
|
|
56
|
+
download_parser.add_argument("local_dst", help="本地目标路径")
|
|
57
|
+
|
|
58
|
+
subparsers.add_parser("help", help="显示帮助信息")
|
|
59
|
+
|
|
60
|
+
return parser
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def print_help():
|
|
64
|
+
build_parser().print_help()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def main(argv=None):
|
|
68
|
+
parser = build_parser()
|
|
69
|
+
args = parser.parse_args(argv)
|
|
70
|
+
|
|
71
|
+
if args.command == "help":
|
|
72
|
+
print_help()
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
if args.command == "login":
|
|
76
|
+
try:
|
|
77
|
+
login()
|
|
78
|
+
except ConfigError as error:
|
|
79
|
+
print(f"错误: {error}", file=sys.stderr)
|
|
80
|
+
return 1
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
if args.command is None:
|
|
84
|
+
try:
|
|
85
|
+
print("正在读取配置...", flush=True)
|
|
86
|
+
config = load()
|
|
87
|
+
print(f"正在连接 {config.bucket}...", flush=True)
|
|
88
|
+
session = OssSession(config)
|
|
89
|
+
run_shell(session)
|
|
90
|
+
except ConfigError as error:
|
|
91
|
+
print(f"错误: {error}", file=sys.stderr)
|
|
92
|
+
return 1
|
|
93
|
+
except KeyboardInterrupt:
|
|
94
|
+
print()
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
config = load()
|
|
99
|
+
session = OssSession(config)
|
|
100
|
+
except ConfigError as error:
|
|
101
|
+
print(f"错误: {error}", file=sys.stderr)
|
|
102
|
+
return 1
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
if args.command == "upload":
|
|
106
|
+
oss_path = parse_oss_uri(args.oss_dst, session.bucket_name)
|
|
107
|
+
OssUpload(session).upload(args.local_src, oss_path)
|
|
108
|
+
elif args.command == "download":
|
|
109
|
+
oss_path = parse_oss_uri(args.oss_src, session.bucket_name)
|
|
110
|
+
OssDownload(session).download(oss_path, args.local_dst)
|
|
111
|
+
else:
|
|
112
|
+
print_help()
|
|
113
|
+
return 1
|
|
114
|
+
except Exception as error:
|
|
115
|
+
print(f"错误: {error}", file=sys.stderr)
|
|
116
|
+
return 1
|
|
117
|
+
|
|
118
|
+
return 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
if __name__ == "__main__":
|
|
122
|
+
sys.exit(main())
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
import configparser
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
CONFIG_DIR = os.path.expanduser("~/.se_ossutil")
|
|
8
|
+
CONFIG_PATH = os.path.join(CONFIG_DIR, "config")
|
|
9
|
+
|
|
10
|
+
DEFAULT_ENDPOINT = "https://oss-cn-wulanchabu.aliyuncs.com"
|
|
11
|
+
DEFAULT_REGION = "cn-wulanchabu"
|
|
12
|
+
DEFAULT_BUCKET = "ali-pai-oss-seene"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class OssConfig:
|
|
17
|
+
access_key_id: str
|
|
18
|
+
access_key_secret: str
|
|
19
|
+
endpoint: str
|
|
20
|
+
region: str
|
|
21
|
+
bucket: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConfigError(Exception):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_oss_uri(uri, default_bucket=None):
|
|
29
|
+
if not uri.startswith("oss://"):
|
|
30
|
+
raise ValueError(f"无效的 OSS 路径: {uri}")
|
|
31
|
+
|
|
32
|
+
path = uri[6:]
|
|
33
|
+
if path.startswith("/"):
|
|
34
|
+
path = path[1:]
|
|
35
|
+
|
|
36
|
+
if not default_bucket:
|
|
37
|
+
return path
|
|
38
|
+
|
|
39
|
+
if path == default_bucket or path == f"{default_bucket}/":
|
|
40
|
+
return ""
|
|
41
|
+
|
|
42
|
+
bucket_prefix = f"{default_bucket}/"
|
|
43
|
+
if path.startswith(bucket_prefix):
|
|
44
|
+
return path[len(bucket_prefix):]
|
|
45
|
+
|
|
46
|
+
return path
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def normalize_prefix(prefix):
|
|
50
|
+
if not prefix:
|
|
51
|
+
return ""
|
|
52
|
+
return prefix if prefix.endswith("/") else f"{prefix}/"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _prompt(label, default=""):
|
|
56
|
+
if default:
|
|
57
|
+
value = input(f"{label} [{default}]: ").strip()
|
|
58
|
+
return value or default
|
|
59
|
+
return input(f"{label}: ").strip()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def login():
|
|
63
|
+
print("请输入 OSS 配置(login 将保存到 ~/.se_ossutil/config)")
|
|
64
|
+
|
|
65
|
+
access_key_id = input("AccessKeyId: ").strip()
|
|
66
|
+
access_key_secret = input("AccessKeySecret: ").strip()
|
|
67
|
+
endpoint = _prompt("Endpoint", DEFAULT_ENDPOINT)
|
|
68
|
+
region = _prompt("Region", DEFAULT_REGION)
|
|
69
|
+
bucket = _prompt("Bucket", DEFAULT_BUCKET)
|
|
70
|
+
|
|
71
|
+
if not all([access_key_id, access_key_secret, endpoint, region, bucket]):
|
|
72
|
+
raise ConfigError("所有配置项均不能为空")
|
|
73
|
+
|
|
74
|
+
config = OssConfig(
|
|
75
|
+
access_key_id=access_key_id,
|
|
76
|
+
access_key_secret=access_key_secret,
|
|
77
|
+
endpoint=endpoint,
|
|
78
|
+
region=region,
|
|
79
|
+
bucket=bucket,
|
|
80
|
+
)
|
|
81
|
+
save(config)
|
|
82
|
+
print(f"配置已保存到 {CONFIG_PATH}")
|
|
83
|
+
return config
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def save(config):
|
|
87
|
+
os.makedirs(CONFIG_DIR, mode=0o700, exist_ok=True)
|
|
88
|
+
|
|
89
|
+
parser = configparser.ConfigParser()
|
|
90
|
+
parser["default"] = {
|
|
91
|
+
"access_key_id": config.access_key_id,
|
|
92
|
+
"access_key_secret": config.access_key_secret,
|
|
93
|
+
"endpoint": config.endpoint,
|
|
94
|
+
"region": config.region,
|
|
95
|
+
"bucket": config.bucket,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
with open(CONFIG_PATH, "w", encoding="utf-8") as file:
|
|
99
|
+
parser.write(file)
|
|
100
|
+
os.chmod(CONFIG_PATH, 0o600)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def load():
|
|
104
|
+
if not os.path.isfile(CONFIG_PATH):
|
|
105
|
+
raise ConfigError("未找到配置,请先运行: se_ossutil login")
|
|
106
|
+
|
|
107
|
+
parser = configparser.ConfigParser()
|
|
108
|
+
parser.read(CONFIG_PATH, encoding="utf-8")
|
|
109
|
+
|
|
110
|
+
if "default" not in parser:
|
|
111
|
+
raise ConfigError("配置文件格式无效,请重新 login")
|
|
112
|
+
|
|
113
|
+
section = parser["default"]
|
|
114
|
+
required = ["access_key_id", "access_key_secret", "endpoint", "region", "bucket"]
|
|
115
|
+
missing = [key for key in required if not section.get(key)]
|
|
116
|
+
if missing:
|
|
117
|
+
raise ConfigError(f"配置文件缺少字段: {', '.join(missing)}")
|
|
118
|
+
|
|
119
|
+
return OssConfig(
|
|
120
|
+
access_key_id=section["access_key_id"],
|
|
121
|
+
access_key_secret=section["access_key_secret"],
|
|
122
|
+
endpoint=section["endpoint"],
|
|
123
|
+
region=section["region"],
|
|
124
|
+
bucket=section["bucket"],
|
|
125
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
import oss2
|
|
4
|
+
|
|
5
|
+
from .config import normalize_prefix
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def mkdir(session, name):
|
|
9
|
+
key = session.resolve(name)
|
|
10
|
+
prefix = normalize_prefix(key)
|
|
11
|
+
session.bucket.put_object(prefix, b"")
|
|
12
|
+
print(f"已创建目录: {prefix}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _confirm(message):
|
|
16
|
+
answer = input(f"{message}\n输入 yes 确认: ").strip()
|
|
17
|
+
return answer == "yes"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _delete_target(session, key):
|
|
21
|
+
prefix = normalize_prefix(key)
|
|
22
|
+
|
|
23
|
+
if _is_directory(session, key):
|
|
24
|
+
deleted = 0
|
|
25
|
+
for obj in oss2.ObjectIterator(session.bucket, prefix=prefix):
|
|
26
|
+
session.bucket.delete_object(obj.key)
|
|
27
|
+
deleted += 1
|
|
28
|
+
print(f"已删除目录: {prefix} ({deleted} 个对象)")
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
target = key.rstrip("/")
|
|
32
|
+
session.bucket.delete_object(target)
|
|
33
|
+
print(f"已删除: {target}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def rm(session, name, force=False):
|
|
37
|
+
key = session.resolve(name)
|
|
38
|
+
target = normalize_prefix(key) if _is_directory(session, key) else key.rstrip("/")
|
|
39
|
+
|
|
40
|
+
if not force and not _confirm(f"确认删除 {target}?"):
|
|
41
|
+
print("已取消")
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
_delete_target(session, key)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _is_directory(session, key):
|
|
48
|
+
prefix = normalize_prefix(key)
|
|
49
|
+
result = session.bucket.list_objects_v2(
|
|
50
|
+
prefix=prefix,
|
|
51
|
+
delimiter="/",
|
|
52
|
+
max_keys=1,
|
|
53
|
+
)
|
|
54
|
+
if result.prefix_list:
|
|
55
|
+
return True
|
|
56
|
+
for obj in result.object_list:
|
|
57
|
+
if obj.key != prefix.rstrip("/"):
|
|
58
|
+
return True
|
|
59
|
+
return key.endswith("/")
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import oss2
|
|
7
|
+
from oss2 import exceptions as oss_exceptions
|
|
8
|
+
|
|
9
|
+
from .config import normalize_prefix
|
|
10
|
+
from .session import CONNECT_TIMEOUT, MAX_RETRIES
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OssDownload:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
session,
|
|
17
|
+
multipart_threshold=100 * 1024 * 1024,
|
|
18
|
+
part_size=100 * 1024 * 1024,
|
|
19
|
+
num_threads=4,
|
|
20
|
+
connect_timeout=CONNECT_TIMEOUT,
|
|
21
|
+
max_retries=MAX_RETRIES,
|
|
22
|
+
):
|
|
23
|
+
self.session = session
|
|
24
|
+
self.bucket = session.bucket
|
|
25
|
+
self.multipart_threshold = multipart_threshold
|
|
26
|
+
self.part_size = part_size
|
|
27
|
+
self.num_threads = num_threads
|
|
28
|
+
self.connect_timeout = connect_timeout
|
|
29
|
+
self.max_retries = max_retries
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def _should_retry(error):
|
|
33
|
+
if isinstance(error, oss_exceptions.RequestError):
|
|
34
|
+
return True
|
|
35
|
+
if isinstance(error, oss_exceptions.ServerError):
|
|
36
|
+
return error.status // 100 == 5
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
def _make_file_progress(self):
|
|
40
|
+
start_time = None
|
|
41
|
+
last_time = None
|
|
42
|
+
last_bytes = 0
|
|
43
|
+
speed_mb = 0.0
|
|
44
|
+
|
|
45
|
+
def progress(consumed, total):
|
|
46
|
+
nonlocal start_time, last_time, last_bytes, speed_mb
|
|
47
|
+
if not total:
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
now = time.monotonic()
|
|
51
|
+
if start_time is None:
|
|
52
|
+
start_time = now
|
|
53
|
+
|
|
54
|
+
if last_time is not None:
|
|
55
|
+
delta_time = now - last_time
|
|
56
|
+
delta_bytes = consumed - last_bytes
|
|
57
|
+
if delta_time > 0 and delta_bytes >= 0:
|
|
58
|
+
speed_mb = (delta_bytes / delta_time) / (1024 * 1024)
|
|
59
|
+
|
|
60
|
+
last_time = now
|
|
61
|
+
last_bytes = consumed
|
|
62
|
+
|
|
63
|
+
consumed_mb = consumed / (1024 * 1024)
|
|
64
|
+
total_mb = total / (1024 * 1024)
|
|
65
|
+
pct = consumed * 100 // total
|
|
66
|
+
|
|
67
|
+
print(
|
|
68
|
+
f"\r下载进度: {pct}% ({consumed_mb:.1f}/{total_mb:.1f} MB) "
|
|
69
|
+
f"速度: {speed_mb:.1f} MB/s",
|
|
70
|
+
end="",
|
|
71
|
+
flush=True,
|
|
72
|
+
)
|
|
73
|
+
if consumed >= total:
|
|
74
|
+
elapsed = now - start_time
|
|
75
|
+
avg_mb = (total / elapsed / (1024 * 1024)) if elapsed > 0 else 0
|
|
76
|
+
print(f"\n平均速度: {avg_mb:.1f} MB/s, 耗时: {elapsed:.1f}s")
|
|
77
|
+
|
|
78
|
+
return progress
|
|
79
|
+
|
|
80
|
+
def _resumable_download(self, key, filename, progress_callback=None):
|
|
81
|
+
for attempt in range(1, self.max_retries + 2):
|
|
82
|
+
try:
|
|
83
|
+
return oss2.resumable_download(
|
|
84
|
+
self.bucket,
|
|
85
|
+
key,
|
|
86
|
+
filename,
|
|
87
|
+
multiget_threshold=self.multipart_threshold,
|
|
88
|
+
part_size=self.part_size,
|
|
89
|
+
num_threads=self.num_threads,
|
|
90
|
+
progress_callback=progress_callback,
|
|
91
|
+
)
|
|
92
|
+
except Exception as error:
|
|
93
|
+
if not self._should_retry(error) or attempt > self.max_retries:
|
|
94
|
+
raise
|
|
95
|
+
print(f"\n下载失败 ({attempt}/{self.max_retries + 1}): {error},重试中...")
|
|
96
|
+
|
|
97
|
+
def download_file(self, oss_key, local_path):
|
|
98
|
+
parent = os.path.dirname(local_path)
|
|
99
|
+
if parent:
|
|
100
|
+
os.makedirs(parent, exist_ok=True)
|
|
101
|
+
|
|
102
|
+
print(f"OSS Key: {oss_key}")
|
|
103
|
+
print(f"本地文件: {local_path}")
|
|
104
|
+
print(f"连接超时: {self.connect_timeout}s,最大重试: {self.max_retries} 次")
|
|
105
|
+
|
|
106
|
+
self._resumable_download(
|
|
107
|
+
oss_key,
|
|
108
|
+
local_path,
|
|
109
|
+
progress_callback=self._make_file_progress(),
|
|
110
|
+
)
|
|
111
|
+
print("下载完成")
|
|
112
|
+
|
|
113
|
+
def download_folder(self, oss_prefix, local_dir):
|
|
114
|
+
prefix = normalize_prefix(oss_prefix)
|
|
115
|
+
os.makedirs(local_dir, exist_ok=True)
|
|
116
|
+
|
|
117
|
+
objects = [
|
|
118
|
+
obj.key
|
|
119
|
+
for obj in oss2.ObjectIterator(self.bucket, prefix=prefix)
|
|
120
|
+
if obj.key != prefix
|
|
121
|
+
]
|
|
122
|
+
if not objects:
|
|
123
|
+
print("目录为空,无需下载")
|
|
124
|
+
return []
|
|
125
|
+
|
|
126
|
+
print(f"OSS 前缀: {prefix}")
|
|
127
|
+
print(f"本地目录: {local_dir}")
|
|
128
|
+
print(f"文件数量: {len(objects)}")
|
|
129
|
+
|
|
130
|
+
for index, key in enumerate(objects, start=1):
|
|
131
|
+
rel_path = key[len(prefix):]
|
|
132
|
+
local_path = os.path.join(local_dir, rel_path.replace("/", os.sep))
|
|
133
|
+
parent = os.path.dirname(local_path)
|
|
134
|
+
if parent:
|
|
135
|
+
os.makedirs(parent, exist_ok=True)
|
|
136
|
+
|
|
137
|
+
print(f"\n开始下载 ({index}/{len(objects)}): {rel_path}")
|
|
138
|
+
self._resumable_download(key, local_path)
|
|
139
|
+
print(f"完成: {rel_path}")
|
|
140
|
+
|
|
141
|
+
print(f"\n全部下载完成,共 {len(objects)} 个文件")
|
|
142
|
+
return objects
|
|
143
|
+
|
|
144
|
+
def download(self, oss_path, local_dst):
|
|
145
|
+
if oss_path.endswith("/"):
|
|
146
|
+
return self.download_folder(normalize_prefix(oss_path), local_dst)
|
|
147
|
+
|
|
148
|
+
if self.bucket.object_exists(oss_path):
|
|
149
|
+
if os.path.isdir(local_dst):
|
|
150
|
+
local_path = os.path.join(local_dst, os.path.basename(oss_path))
|
|
151
|
+
else:
|
|
152
|
+
local_path = local_dst
|
|
153
|
+
return self.download_file(oss_path, local_path)
|
|
154
|
+
|
|
155
|
+
prefix = normalize_prefix(oss_path)
|
|
156
|
+
if self._has_objects(prefix):
|
|
157
|
+
return self.download_folder(prefix, local_dst)
|
|
158
|
+
|
|
159
|
+
raise FileNotFoundError(f"OSS 路径不存在: {oss_path}")
|
|
160
|
+
|
|
161
|
+
def _has_objects(self, prefix):
|
|
162
|
+
iterator = oss2.ObjectIterator(self.bucket, prefix=prefix, max_keys=1)
|
|
163
|
+
try:
|
|
164
|
+
next(iterator)
|
|
165
|
+
return True
|
|
166
|
+
except StopIteration:
|
|
167
|
+
return False
|