se-ossutil 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.
se_ossutil/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # -*- coding: utf-8 -*-
se_ossutil/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
se_ossutil/cli.py ADDED
@@ -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())
se_ossutil/config.py ADDED
@@ -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