pewutils 0.0.1__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.
- pewutils-0.0.1/PKG-INFO +121 -0
- pewutils-0.0.1/README.md +102 -0
- pewutils-0.0.1/pewutils/__init__.py +23 -0
- pewutils-0.0.1/pewutils/oss_util.py +524 -0
- pewutils-0.0.1/pewutils/url_util.py +231 -0
- pewutils-0.0.1/pewutils/wmill_util.py +108 -0
- pewutils-0.0.1/pewutils.egg-info/PKG-INFO +121 -0
- pewutils-0.0.1/pewutils.egg-info/SOURCES.txt +11 -0
- pewutils-0.0.1/pewutils.egg-info/dependency_links.txt +1 -0
- pewutils-0.0.1/pewutils.egg-info/requires.txt +2 -0
- pewutils-0.0.1/pewutils.egg-info/top_level.txt +1 -0
- pewutils-0.0.1/pyproject.toml +39 -0
- pewutils-0.0.1/setup.cfg +4 -0
pewutils-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pewutils
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Lightweight utils for windmill scripts (migrated from peutils).
|
|
5
|
+
Author-email: rxu <rxu@appen.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://gitee.com/yunsansheng/pewutils
|
|
8
|
+
Keywords: pewutils,windmill,pe
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: oss2>=2.15
|
|
18
|
+
Requires-Dist: requests>=2.25
|
|
19
|
+
|
|
20
|
+
# pewutils
|
|
21
|
+
|
|
22
|
+
PE Windmill Utils —— 为 Windmill 脚本平台提供的轻量级 Python 工具包。
|
|
23
|
+
|
|
24
|
+
由 `peutils` 迁移重构而来,适配 windmill 脚本平台。
|
|
25
|
+
|
|
26
|
+
## 已迁移模块
|
|
27
|
+
|
|
28
|
+
| 模块 | 说明 | 依赖 |
|
|
29
|
+
|------|------|------|
|
|
30
|
+
| `url_util` | URL 处理工具(解析、存储 URL、签名 URL 清理、查询参数等) | 仅标准库 |
|
|
31
|
+
| `oss_util` | 阿里云 OSS 工具(STS 鉴权、列举、复制、搜索) | oss2, requests |
|
|
32
|
+
| `wmill_util` | Windmill 平台工具(zip 打包上传 S3) | wmill(windmill runtime 内置) |
|
|
33
|
+
|
|
34
|
+
## 安装
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# 核心包(含 oss2/requests 依赖)
|
|
38
|
+
pip install pewutils
|
|
39
|
+
|
|
40
|
+
# 本地开发(editable)
|
|
41
|
+
git clone <repo-url> pewutils
|
|
42
|
+
cd pewutils
|
|
43
|
+
pip install -e .
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## 使用示例
|
|
47
|
+
|
|
48
|
+
### URL 工具
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from pewutils.url_util import (
|
|
52
|
+
get_clean_url,
|
|
53
|
+
parse_storage_url,
|
|
54
|
+
parse_oss_full_path,
|
|
55
|
+
get_relative_path,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# 去除 OSS 签名 URL 的查询参数
|
|
59
|
+
clean = get_clean_url("https://bucket.aliyuncs.com/path/file.pcd?Expires=123&Signature=xxx")
|
|
60
|
+
# -> "https://bucket.aliyuncs.com/path/file.pcd"
|
|
61
|
+
|
|
62
|
+
# 解析存储 URL(宽松)
|
|
63
|
+
bucket, path = parse_storage_url("oss://my-bucket/folder/file.txt")
|
|
64
|
+
# -> ("my-bucket", "folder/file.txt")
|
|
65
|
+
|
|
66
|
+
# 解析 OSS 目录路径(强约束,要求以 / 结尾)
|
|
67
|
+
bucket, folder = parse_oss_full_path("oss://my-bucket/folder/")
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### OSS 工具
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from pewutils.oss_util import OSSClientFactory
|
|
74
|
+
|
|
75
|
+
# 创建带自动刷新的 OSS client(auth_str 12h 过期,11h 自动刷新)
|
|
76
|
+
client = OSSClientFactory.create_client(bucket_name="my-bucket")
|
|
77
|
+
|
|
78
|
+
# 读取 OSS 对象为字节流
|
|
79
|
+
data = client.read_bytes("path/to/file.pcd")
|
|
80
|
+
|
|
81
|
+
# 列举目录下所有文件
|
|
82
|
+
files = client.list_bucket_files_deep("path/to/folder/")
|
|
83
|
+
|
|
84
|
+
# BFS 查找最浅的目标文件
|
|
85
|
+
path = client.find_shallowest_target("root/", "target.json", "file")
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Windmill 平台工具
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from pewutils.wmill_util import zip_to_s3
|
|
92
|
+
|
|
93
|
+
# with 块内生成文件,退出时自动打包 zip 上传到 Windmill S3
|
|
94
|
+
with zip_to_s3("output/zip", suffix=".json") as out:
|
|
95
|
+
Path("a.json").write_text('{"k": 1}')
|
|
96
|
+
Path("b.json").write_text('{"k": 2}')
|
|
97
|
+
# out.result -> S3Object(s3="output/zip/20260729/uuid.zip")
|
|
98
|
+
# out.files -> [Path("a.json"), Path("b.json")]
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## 目录结构
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
pewutils/
|
|
105
|
+
├── README.md
|
|
106
|
+
├── pyproject.toml # 打包配置(PEP 517/518,requires-python>=3.10)
|
|
107
|
+
├── .gitignore
|
|
108
|
+
└── pewutils/
|
|
109
|
+
├── __init__.py # 默认导出 url_util
|
|
110
|
+
├── url_util.py # URL 处理(纯标准库)
|
|
111
|
+
├── oss_util.py # OSS 工具(oss2, requests)
|
|
112
|
+
└── wmill_util.py # Windmill 平台工具(wmill lazy import)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## 构建
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
pip install build
|
|
119
|
+
python -m build
|
|
120
|
+
# 产物:dist/pewutils-<version>-py3-none-any.whl
|
|
121
|
+
```
|
pewutils-0.0.1/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# pewutils
|
|
2
|
+
|
|
3
|
+
PE Windmill Utils —— 为 Windmill 脚本平台提供的轻量级 Python 工具包。
|
|
4
|
+
|
|
5
|
+
由 `peutils` 迁移重构而来,适配 windmill 脚本平台。
|
|
6
|
+
|
|
7
|
+
## 已迁移模块
|
|
8
|
+
|
|
9
|
+
| 模块 | 说明 | 依赖 |
|
|
10
|
+
|------|------|------|
|
|
11
|
+
| `url_util` | URL 处理工具(解析、存储 URL、签名 URL 清理、查询参数等) | 仅标准库 |
|
|
12
|
+
| `oss_util` | 阿里云 OSS 工具(STS 鉴权、列举、复制、搜索) | oss2, requests |
|
|
13
|
+
| `wmill_util` | Windmill 平台工具(zip 打包上传 S3) | wmill(windmill runtime 内置) |
|
|
14
|
+
|
|
15
|
+
## 安装
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# 核心包(含 oss2/requests 依赖)
|
|
19
|
+
pip install pewutils
|
|
20
|
+
|
|
21
|
+
# 本地开发(editable)
|
|
22
|
+
git clone <repo-url> pewutils
|
|
23
|
+
cd pewutils
|
|
24
|
+
pip install -e .
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## 使用示例
|
|
28
|
+
|
|
29
|
+
### URL 工具
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from pewutils.url_util import (
|
|
33
|
+
get_clean_url,
|
|
34
|
+
parse_storage_url,
|
|
35
|
+
parse_oss_full_path,
|
|
36
|
+
get_relative_path,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# 去除 OSS 签名 URL 的查询参数
|
|
40
|
+
clean = get_clean_url("https://bucket.aliyuncs.com/path/file.pcd?Expires=123&Signature=xxx")
|
|
41
|
+
# -> "https://bucket.aliyuncs.com/path/file.pcd"
|
|
42
|
+
|
|
43
|
+
# 解析存储 URL(宽松)
|
|
44
|
+
bucket, path = parse_storage_url("oss://my-bucket/folder/file.txt")
|
|
45
|
+
# -> ("my-bucket", "folder/file.txt")
|
|
46
|
+
|
|
47
|
+
# 解析 OSS 目录路径(强约束,要求以 / 结尾)
|
|
48
|
+
bucket, folder = parse_oss_full_path("oss://my-bucket/folder/")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### OSS 工具
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from pewutils.oss_util import OSSClientFactory
|
|
55
|
+
|
|
56
|
+
# 创建带自动刷新的 OSS client(auth_str 12h 过期,11h 自动刷新)
|
|
57
|
+
client = OSSClientFactory.create_client(bucket_name="my-bucket")
|
|
58
|
+
|
|
59
|
+
# 读取 OSS 对象为字节流
|
|
60
|
+
data = client.read_bytes("path/to/file.pcd")
|
|
61
|
+
|
|
62
|
+
# 列举目录下所有文件
|
|
63
|
+
files = client.list_bucket_files_deep("path/to/folder/")
|
|
64
|
+
|
|
65
|
+
# BFS 查找最浅的目标文件
|
|
66
|
+
path = client.find_shallowest_target("root/", "target.json", "file")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Windmill 平台工具
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from pewutils.wmill_util import zip_to_s3
|
|
73
|
+
|
|
74
|
+
# with 块内生成文件,退出时自动打包 zip 上传到 Windmill S3
|
|
75
|
+
with zip_to_s3("output/zip", suffix=".json") as out:
|
|
76
|
+
Path("a.json").write_text('{"k": 1}')
|
|
77
|
+
Path("b.json").write_text('{"k": 2}')
|
|
78
|
+
# out.result -> S3Object(s3="output/zip/20260729/uuid.zip")
|
|
79
|
+
# out.files -> [Path("a.json"), Path("b.json")]
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## 目录结构
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
pewutils/
|
|
86
|
+
├── README.md
|
|
87
|
+
├── pyproject.toml # 打包配置(PEP 517/518,requires-python>=3.10)
|
|
88
|
+
├── .gitignore
|
|
89
|
+
└── pewutils/
|
|
90
|
+
├── __init__.py # 默认导出 url_util
|
|
91
|
+
├── url_util.py # URL 处理(纯标准库)
|
|
92
|
+
├── oss_util.py # OSS 工具(oss2, requests)
|
|
93
|
+
└── wmill_util.py # Windmill 平台工具(wmill lazy import)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## 构建
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
pip install build
|
|
100
|
+
python -m build
|
|
101
|
+
# 产物:dist/pewutils-<version>-py3-none-any.whl
|
|
102
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
pewutils - PE Windmill Utils
|
|
5
|
+
为 Windmill 脚本平台提供的轻量级 Python 工具包。
|
|
6
|
+
|
|
7
|
+
设计原则:
|
|
8
|
+
- 核心包零重依赖,保证 windmill 脚本冷启动快
|
|
9
|
+
- 重依赖(oss2/open3d/opencv/pandas 等)按需拆分到可选子包或独立模块
|
|
10
|
+
- 由 wind_scripts 脚本通过 `# requirements: pewutils==x.x.x` 引用
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
__version__ = "0.0.1"
|
|
14
|
+
__author__ = "rxu"
|
|
15
|
+
|
|
16
|
+
# 默认导出轻量模块(纯标准库)
|
|
17
|
+
from . import url_util
|
|
18
|
+
|
|
19
|
+
# 其他模块按需导入(依赖已在 dependencies 里,直接 import 即可)
|
|
20
|
+
# from . import oss_util
|
|
21
|
+
# from . import wmill_util
|
|
22
|
+
|
|
23
|
+
__all__ = ["url_util"]
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
pewutils.oss_util - 阿里云 OSS 工具
|
|
5
|
+
从 peutils.ossutil 迁移,基于 oss2 + STS 鉴权。
|
|
6
|
+
|
|
7
|
+
Change History:
|
|
8
|
+
2023-11-02: 初始版本(peutils ossutil.py, Henry.Wang)
|
|
9
|
+
2026-07-29: 迁移至 pewutils,去除 textutil/comutil 依赖
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import platform
|
|
16
|
+
import subprocess
|
|
17
|
+
import time
|
|
18
|
+
from collections import deque
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
import oss2
|
|
23
|
+
from oss2 import determine_part_size
|
|
24
|
+
from oss2.models import PartInfo
|
|
25
|
+
|
|
26
|
+
import requests
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"get_oss_auth_str",
|
|
30
|
+
"get_long_object_link",
|
|
31
|
+
"parse_auth_token",
|
|
32
|
+
"OSS_STS_API",
|
|
33
|
+
"OSSClientProxy",
|
|
34
|
+
"OSSClientFactory",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ============================================================
|
|
39
|
+
# 辅助函数
|
|
40
|
+
# ============================================================
|
|
41
|
+
|
|
42
|
+
def _get_device_sn() -> str:
|
|
43
|
+
"""获取设备序列号,用于 Linux VPC 鉴权。"""
|
|
44
|
+
sys_kind = platform.system()
|
|
45
|
+
if sys_kind == "Darwin":
|
|
46
|
+
cmd = "ioreg -l | grep IOPlatformSerialNumber"
|
|
47
|
+
result = subprocess.check_output(cmd, shell=True).decode("utf-8")
|
|
48
|
+
return result.split(" ")[-1].strip('\n').strip('"')
|
|
49
|
+
elif sys_kind == "Windows":
|
|
50
|
+
try:
|
|
51
|
+
cmd = 'powershell -Command "Get-WmiObject Win32_BIOS | Select-Object -ExpandProperty SerialNumber"'
|
|
52
|
+
return subprocess.check_output(cmd, shell=True).decode("utf-8").strip()
|
|
53
|
+
except Exception:
|
|
54
|
+
cmd = "wmic bios get serialnumber"
|
|
55
|
+
result = subprocess.check_output(cmd, shell=True).decode("utf-8")
|
|
56
|
+
return result.split("\n")[1].strip()
|
|
57
|
+
else:
|
|
58
|
+
raise Exception(f"unknown system kind: {sys_kind}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_auth_token(token: str) -> dict:
|
|
62
|
+
"""解析 STS auth_str,返回包含 id/secret/stoken/osspath/region 的 dict。
|
|
63
|
+
|
|
64
|
+
替代原 peutils.textutil.parse_info_from_token。
|
|
65
|
+
"""
|
|
66
|
+
return json.loads(base64.b64decode(token).decode())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ============================================================
|
|
70
|
+
# 鉴权接口
|
|
71
|
+
# ============================================================
|
|
72
|
+
|
|
73
|
+
def get_oss_auth_str(oss_path: str, auth_type: str = "read", is_print: bool = True) -> str:
|
|
74
|
+
"""调用鉴权服务获取 STS auth_str。
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
oss_path: OSS 路径,如 "oss://bucket/"
|
|
78
|
+
auth_type: 权限类型,"read" 或 "re_up"
|
|
79
|
+
is_print: 是否打印 auth_str(12 小时过期)
|
|
80
|
+
"""
|
|
81
|
+
headers = {"Content-Type": "application/json"}
|
|
82
|
+
payload = {"oss_path": oss_path, "auth_type": auth_type}
|
|
83
|
+
|
|
84
|
+
sys_kind = platform.system()
|
|
85
|
+
if sys_kind == "Linux":
|
|
86
|
+
url = "https://dataflow.appen.com.cn/oss_auth/other/oss_auth_from_vpc/"
|
|
87
|
+
else:
|
|
88
|
+
url = "https://dataflow.appen.com.cn/oss_auth/other/oss_auth_from_debug/"
|
|
89
|
+
payload["sn_no"] = _get_device_sn()
|
|
90
|
+
|
|
91
|
+
r = requests.post(url=url, json=payload, headers=headers)
|
|
92
|
+
if r.status_code == 403:
|
|
93
|
+
ip_info = requests.get("https://jsonip.com").json().get("ip")
|
|
94
|
+
raise Exception(f"{url.split('/')[-2]} 当前未开通白名单 {ip_info}")
|
|
95
|
+
|
|
96
|
+
data = r.json()
|
|
97
|
+
if data["code"] != 200:
|
|
98
|
+
raise Exception(f"授权接口调用失败,具体原因: {data['message']}")
|
|
99
|
+
auth_str = data["data"]["auth_str"]
|
|
100
|
+
if is_print:
|
|
101
|
+
print("请复制下方的授权码,使用oss登陆,授权码将在12小时后过期,请尽快使用!")
|
|
102
|
+
print(auth_str)
|
|
103
|
+
return auth_str
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def get_long_object_link(
|
|
107
|
+
auth_str: str, bucket_name: str, file_key: str, duration: int = 604800
|
|
108
|
+
) -> str:
|
|
109
|
+
"""获取 OSS 对象的长期访问 URL(默认 7 天)。
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
auth_str: STS auth_str
|
|
113
|
+
bucket_name: bucket 名称
|
|
114
|
+
file_key: 对象 key
|
|
115
|
+
duration: 有效期(秒)
|
|
116
|
+
"""
|
|
117
|
+
headers = {"Content-Type": "application/json"}
|
|
118
|
+
payload = {
|
|
119
|
+
"authToken": auth_str,
|
|
120
|
+
"filePath": file_key,
|
|
121
|
+
"bucketName": bucket_name,
|
|
122
|
+
"expiration": duration,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
r = requests.post(
|
|
126
|
+
url="https://dataflow.appen.com.cn/oss_app/other/get_oss_file_url/",
|
|
127
|
+
json=payload,
|
|
128
|
+
headers=headers,
|
|
129
|
+
)
|
|
130
|
+
rsp = r.json()
|
|
131
|
+
if rsp["code"] != 200:
|
|
132
|
+
raise Exception(rsp["message"])
|
|
133
|
+
return rsp["data"]["url"]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ============================================================
|
|
137
|
+
# OSS STS API
|
|
138
|
+
# ============================================================
|
|
139
|
+
|
|
140
|
+
class OSS_STS_API:
|
|
141
|
+
"""阿里云 OSS STS 鉴权客户端。
|
|
142
|
+
|
|
143
|
+
自动通过鉴权服务获取 STS 凭证,支持 Linux VPC 内网访问。
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def __init__(
|
|
147
|
+
self,
|
|
148
|
+
bucket_name: Optional[str] = None,
|
|
149
|
+
time_out: int = 60,
|
|
150
|
+
region: Optional[str] = None,
|
|
151
|
+
always_public: bool = False,
|
|
152
|
+
auth_str: Optional[str] = None,
|
|
153
|
+
):
|
|
154
|
+
if auth_str:
|
|
155
|
+
self.auth_str = auth_str
|
|
156
|
+
self.auth_dict = parse_auth_token(self.auth_str)
|
|
157
|
+
from urllib.parse import urlparse
|
|
158
|
+
self.bucket_name = urlparse(self.auth_dict["osspath"]).hostname
|
|
159
|
+
else:
|
|
160
|
+
assert bucket_name, "bucket_name 和 auth_str 必须二选一"
|
|
161
|
+
self.bucket_name = bucket_name
|
|
162
|
+
oss_path = f"oss://{self.bucket_name}/"
|
|
163
|
+
self.auth_str = get_oss_auth_str(oss_path, auth_type="re_up", is_print=False)
|
|
164
|
+
self.auth_dict = parse_auth_token(self.auth_str)
|
|
165
|
+
|
|
166
|
+
self.auth = oss2.StsAuth(
|
|
167
|
+
self.auth_dict["id"],
|
|
168
|
+
self.auth_dict["secret"],
|
|
169
|
+
self.auth_dict["stoken"],
|
|
170
|
+
)
|
|
171
|
+
self.short_region = self.auth_dict["region"]
|
|
172
|
+
|
|
173
|
+
# 默认根据操作系统选择 endpoint
|
|
174
|
+
sys_kind = platform.system()
|
|
175
|
+
if region is None:
|
|
176
|
+
if sys_kind == "Linux":
|
|
177
|
+
region = f"http://{self.short_region}-internal.aliyuncs.com"
|
|
178
|
+
else:
|
|
179
|
+
region = f"http://{self.short_region}.aliyuncs.com"
|
|
180
|
+
|
|
181
|
+
if always_public:
|
|
182
|
+
region = f"http://{self.short_region}.aliyuncs.com"
|
|
183
|
+
|
|
184
|
+
self.region = region
|
|
185
|
+
self.bucket = oss2.Bucket(self.auth, region, self.bucket_name, connect_timeout=time_out)
|
|
186
|
+
|
|
187
|
+
# ------------------------------------------------------------
|
|
188
|
+
# 读取
|
|
189
|
+
# ------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
def read_bytes(self, oss_key: str) -> bytes:
|
|
192
|
+
"""读取 OSS 对象为字节流。
|
|
193
|
+
|
|
194
|
+
替代散落的 `bucket.get_object(key).read()` 调用,方便配合 pcd_util.read_pcd_from_bytes 等使用。
|
|
195
|
+
"""
|
|
196
|
+
return self.bucket.get_object(oss_key).read()
|
|
197
|
+
|
|
198
|
+
def check_is_empty_oss_folder(self, oss_key: str) -> bool:
|
|
199
|
+
"""检查 OSS 文件夹是否为空(oss_key 必须以 / 结尾)。"""
|
|
200
|
+
assert oss_key.endswith("/"), "oss_key必须以/结尾"
|
|
201
|
+
assert not oss_key.startswith("/"), "oss_key不能以/开始"
|
|
202
|
+
is_empty = True
|
|
203
|
+
count = 1
|
|
204
|
+
for obj in oss2.ObjectIterator(self.bucket, prefix=oss_key):
|
|
205
|
+
if count > 2:
|
|
206
|
+
break
|
|
207
|
+
if obj.key != oss_key:
|
|
208
|
+
is_empty = False
|
|
209
|
+
count += 1
|
|
210
|
+
return is_empty
|
|
211
|
+
|
|
212
|
+
# ------------------------------------------------------------
|
|
213
|
+
# 列举
|
|
214
|
+
# ------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
def list_bucket_files_deep(
|
|
217
|
+
self, oss_path: str, suffix: str = "", with_bucket_name: bool = False
|
|
218
|
+
) -> list[str]:
|
|
219
|
+
"""深度列举 bucket 下所有文件,忽略临时/隐藏文件。"""
|
|
220
|
+
if not oss_path.endswith("/"):
|
|
221
|
+
oss_path = oss_path + "/"
|
|
222
|
+
|
|
223
|
+
path_list = []
|
|
224
|
+
ignore_list = []
|
|
225
|
+
for obj in oss2.ObjectIterator(self.bucket, prefix=oss_path):
|
|
226
|
+
if not obj.key.endswith("/") and obj.key != oss_path and obj.key.endswith(suffix):
|
|
227
|
+
basename = obj.key.split("/")[-1]
|
|
228
|
+
if basename.startswith((".", "~")):
|
|
229
|
+
ignore_list.append(obj.key)
|
|
230
|
+
else:
|
|
231
|
+
path_list.append(obj.key)
|
|
232
|
+
|
|
233
|
+
if ignore_list:
|
|
234
|
+
print(f"!请注意:{oss_path}下已自动忽略文件: {ignore_list}")
|
|
235
|
+
|
|
236
|
+
if with_bucket_name:
|
|
237
|
+
return [f"/{self.bucket_name}/" + x for x in path_list]
|
|
238
|
+
return path_list
|
|
239
|
+
|
|
240
|
+
def list_bucket_folders_deep(
|
|
241
|
+
self, oss_path: str, with_bucket_name: bool = False
|
|
242
|
+
) -> list[str]:
|
|
243
|
+
"""深度列举 bucket 下所有空叶子目录。"""
|
|
244
|
+
if not oss_path.endswith("/"):
|
|
245
|
+
oss_path = oss_path + "/"
|
|
246
|
+
|
|
247
|
+
path_list = []
|
|
248
|
+
for obj in oss2.ObjectIterator(self.bucket, prefix=oss_path):
|
|
249
|
+
if obj.key.endswith("/") and obj.key != oss_path:
|
|
250
|
+
fd_list = self.list_bucket_current(oss_path=obj.key, list_type="folder")
|
|
251
|
+
if len(fd_list) == 0:
|
|
252
|
+
path_list.append(obj.key)
|
|
253
|
+
assert len(path_list) == len(set(path_list)), "folder出现重复"
|
|
254
|
+
|
|
255
|
+
if with_bucket_name:
|
|
256
|
+
return [f"/{self.bucket_name}/" + x for x in path_list]
|
|
257
|
+
return path_list
|
|
258
|
+
|
|
259
|
+
def list_bucket_current(
|
|
260
|
+
self,
|
|
261
|
+
oss_path: str,
|
|
262
|
+
list_type: str,
|
|
263
|
+
suffix: str = "",
|
|
264
|
+
full_path: bool = True,
|
|
265
|
+
) -> list[str]:
|
|
266
|
+
"""列举当前层级(非递归)的文件或文件夹。
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
list_type: "folder" 或 "file"
|
|
270
|
+
suffix: 文件后缀过滤(仅 list_type='file' 生效)
|
|
271
|
+
full_path: True 返回完整路径,False 返回 basename
|
|
272
|
+
"""
|
|
273
|
+
if not oss_path.endswith("/"):
|
|
274
|
+
oss_path = oss_path + "/"
|
|
275
|
+
|
|
276
|
+
if list_type == "folder":
|
|
277
|
+
fd_list = [
|
|
278
|
+
obj.key
|
|
279
|
+
for obj in oss2.ObjectIterator(self.bucket, prefix=oss_path, delimiter="/")
|
|
280
|
+
if obj.key.endswith("/") and obj.key != oss_path
|
|
281
|
+
]
|
|
282
|
+
if full_path:
|
|
283
|
+
return fd_list
|
|
284
|
+
return [os.path.basename(x[:-1]) for x in fd_list]
|
|
285
|
+
|
|
286
|
+
elif list_type == "file":
|
|
287
|
+
path_list = []
|
|
288
|
+
ignore_list = []
|
|
289
|
+
for obj in oss2.ObjectIterator(self.bucket, prefix=oss_path, delimiter="/"):
|
|
290
|
+
if not obj.key.endswith("/") and obj.key != oss_path and obj.key.endswith(suffix):
|
|
291
|
+
basename = obj.key.split("/")[-1]
|
|
292
|
+
if basename.startswith((".", "~")):
|
|
293
|
+
ignore_list.append(obj.key)
|
|
294
|
+
else:
|
|
295
|
+
path_list.append(obj.key)
|
|
296
|
+
if ignore_list:
|
|
297
|
+
print(f"!请注意:{oss_path}下已自动忽略文件: {ignore_list}")
|
|
298
|
+
return path_list
|
|
299
|
+
|
|
300
|
+
else:
|
|
301
|
+
raise Exception("list_type 只支持 'folder' 或 'file'")
|
|
302
|
+
|
|
303
|
+
# ------------------------------------------------------------
|
|
304
|
+
# 元信息 / 复制
|
|
305
|
+
# ------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
def set_obj_meta(self, filename_list: list[str], meta_header: dict):
|
|
308
|
+
"""批量更新对象元信息(Content-Type / Cache-Control 等)。"""
|
|
309
|
+
print(f"即将更新meta信息 {meta_header} ,文件数量{len(filename_list)},过程可能较长")
|
|
310
|
+
for fl in filename_list:
|
|
311
|
+
self.bucket.update_object_meta(fl, headers=meta_header)
|
|
312
|
+
print("meta信息更新完成.")
|
|
313
|
+
|
|
314
|
+
def copy_big_file(self, src_key: str, dest_key: str, src_bucket_name: str) -> int:
|
|
315
|
+
"""分片复制大文件(>1G)。
|
|
316
|
+
|
|
317
|
+
注意:初始化的 bucket 必须是目标 bucket。
|
|
318
|
+
参考 https://help.aliyun.com/document_detail/88465.html
|
|
319
|
+
"""
|
|
320
|
+
if src_bucket_name == self.bucket_name:
|
|
321
|
+
src_bucket = self.bucket
|
|
322
|
+
else:
|
|
323
|
+
src_bucket = oss2.Bucket(self.auth, self.region, src_bucket_name)
|
|
324
|
+
|
|
325
|
+
head_info = src_bucket.head_object(src_key)
|
|
326
|
+
total_size = head_info.content_length
|
|
327
|
+
part_size = determine_part_size(total_size, preferred_size=100 * 1024)
|
|
328
|
+
|
|
329
|
+
upload_id = self.bucket.init_multipart_upload(dest_key).upload_id
|
|
330
|
+
parts = []
|
|
331
|
+
|
|
332
|
+
part_number = 1
|
|
333
|
+
offset = 0
|
|
334
|
+
while offset < total_size:
|
|
335
|
+
num_to_upload = min(part_size, total_size - offset)
|
|
336
|
+
end = offset + num_to_upload - 1
|
|
337
|
+
result = self.bucket.upload_part_copy(
|
|
338
|
+
src_bucket_name, src_key, (offset, end), dest_key, upload_id, part_number
|
|
339
|
+
)
|
|
340
|
+
parts.append(PartInfo(part_number, result.etag))
|
|
341
|
+
offset += num_to_upload
|
|
342
|
+
part_number += 1
|
|
343
|
+
|
|
344
|
+
result = self.bucket.complete_multipart_upload(dest_key, upload_id, parts)
|
|
345
|
+
head_info = self.bucket.head_object(dest_key)
|
|
346
|
+
assert head_info.content_length == total_size
|
|
347
|
+
return result.status
|
|
348
|
+
|
|
349
|
+
# ------------------------------------------------------------
|
|
350
|
+
# 搜索
|
|
351
|
+
# ------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
def find_shallowest_target(
|
|
354
|
+
self, root: str, target_name: str, target_type: str
|
|
355
|
+
) -> Optional[str]:
|
|
356
|
+
"""BFS 查找路径最浅的目标文件/文件夹。"""
|
|
357
|
+
if target_type not in ("folder", "file"):
|
|
358
|
+
raise Exception("target_type只支持 folder or file")
|
|
359
|
+
if not root.endswith("/"):
|
|
360
|
+
root = f"{root}/"
|
|
361
|
+
|
|
362
|
+
queue = deque([root])
|
|
363
|
+
while queue:
|
|
364
|
+
current_prefix = queue.popleft()
|
|
365
|
+
for obj in oss2.ObjectIterator(self.bucket, prefix=current_prefix, delimiter="/"):
|
|
366
|
+
if obj.key == current_prefix:
|
|
367
|
+
continue
|
|
368
|
+
if obj.is_prefix():
|
|
369
|
+
if target_type == "folder" and Path(obj.key).name == target_name:
|
|
370
|
+
return obj.key
|
|
371
|
+
queue.append(obj.key)
|
|
372
|
+
else:
|
|
373
|
+
if target_type == "file" and Path(obj.key).name == target_name:
|
|
374
|
+
return obj.key
|
|
375
|
+
return None
|
|
376
|
+
|
|
377
|
+
def find_deepest_target(
|
|
378
|
+
self, root: str, target_name: str, target_type: str, current_depth: int = 0
|
|
379
|
+
) -> tuple[Optional[str], int]:
|
|
380
|
+
"""DFS 查找路径最深的目标文件/文件夹。"""
|
|
381
|
+
if target_type not in ("folder", "file"):
|
|
382
|
+
raise Exception("target_type只支持 folder or file")
|
|
383
|
+
if not root.endswith("/"):
|
|
384
|
+
root = f"{root}/"
|
|
385
|
+
|
|
386
|
+
deepest_path = None
|
|
387
|
+
max_depth = current_depth
|
|
388
|
+
|
|
389
|
+
for obj in oss2.ObjectIterator(self.bucket, prefix=root, delimiter="/"):
|
|
390
|
+
if obj.key == root:
|
|
391
|
+
continue
|
|
392
|
+
if obj.is_prefix():
|
|
393
|
+
if target_type == "folder" and Path(obj.key).name == target_name:
|
|
394
|
+
deepest_path, max_depth = obj.key, current_depth
|
|
395
|
+
sub_path, sub_depth = self.find_deepest_target(
|
|
396
|
+
obj.key, target_name, target_type, current_depth + 1
|
|
397
|
+
)
|
|
398
|
+
if sub_path and sub_depth > max_depth:
|
|
399
|
+
deepest_path, max_depth = sub_path, sub_depth
|
|
400
|
+
else:
|
|
401
|
+
if target_type == "file" and Path(obj.key).name == target_name:
|
|
402
|
+
deepest_path, max_depth = obj.key, current_depth
|
|
403
|
+
|
|
404
|
+
return deepest_path, max_depth
|
|
405
|
+
|
|
406
|
+
def find_paths_with_files(
|
|
407
|
+
self, oss_path: str, suffixes=None, debug: bool = False
|
|
408
|
+
) -> list[str]:
|
|
409
|
+
"""深度遍历 OSS 路径,找到包含匹配后缀文件的所有目录。"""
|
|
410
|
+
if not oss_path.endswith("/"):
|
|
411
|
+
oss_path = oss_path + "/"
|
|
412
|
+
|
|
413
|
+
if suffixes is None:
|
|
414
|
+
suffix_list = None
|
|
415
|
+
elif isinstance(suffixes, str):
|
|
416
|
+
suffix_list = [suffixes if suffixes.startswith(".") else f".{suffixes}"]
|
|
417
|
+
else:
|
|
418
|
+
suffix_list = [s if s.startswith(".") else f".{s}" for s in suffixes]
|
|
419
|
+
|
|
420
|
+
if debug:
|
|
421
|
+
print(f"搜索路径: {oss_path}")
|
|
422
|
+
print(f"目标后缀: {suffix_list}")
|
|
423
|
+
|
|
424
|
+
result_paths = set()
|
|
425
|
+
processed_count = 0
|
|
426
|
+
matched_count = 0
|
|
427
|
+
|
|
428
|
+
for obj in oss2.ObjectIterator(self.bucket, prefix=oss_path):
|
|
429
|
+
processed_count += 1
|
|
430
|
+
if obj.key.endswith("/"):
|
|
431
|
+
continue
|
|
432
|
+
|
|
433
|
+
obj_path = Path(obj.key)
|
|
434
|
+
if suffix_list is not None and obj_path.suffix not in suffix_list:
|
|
435
|
+
continue
|
|
436
|
+
|
|
437
|
+
matched_count += 1
|
|
438
|
+
if debug and matched_count <= 10:
|
|
439
|
+
print(f"匹配文件: {obj.key} (后缀: {obj_path.suffix})")
|
|
440
|
+
|
|
441
|
+
parent_str = str(obj_path.parent)
|
|
442
|
+
if not parent_str.endswith("/"):
|
|
443
|
+
parent_str += "/"
|
|
444
|
+
result_paths.add(parent_str)
|
|
445
|
+
|
|
446
|
+
if debug:
|
|
447
|
+
print(f"\n总计: 处理 {processed_count} 个对象, 匹配 {matched_count} 个文件")
|
|
448
|
+
print(f"找到 {len(result_paths)} 个目录")
|
|
449
|
+
|
|
450
|
+
return sorted(result_paths)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# ============================================================
|
|
454
|
+
# 自动刷新代理
|
|
455
|
+
# ============================================================
|
|
456
|
+
|
|
457
|
+
class OSSClientProxy:
|
|
458
|
+
"""OSS client 代理,每次调用前检查凭证是否过期,超过 11 小时自动刷新。
|
|
459
|
+
|
|
460
|
+
auth_str 有效期 12 小时,阈值设为 11 小时。使用外部 auth_str 时不自动刷新。
|
|
461
|
+
"""
|
|
462
|
+
|
|
463
|
+
_ttl_seconds: int = 11 * 60 * 60
|
|
464
|
+
|
|
465
|
+
def __init__(
|
|
466
|
+
self,
|
|
467
|
+
client: OSS_STS_API,
|
|
468
|
+
created_time: float,
|
|
469
|
+
bucket_name: str,
|
|
470
|
+
time_out: int = 60,
|
|
471
|
+
region: Optional[str] = None,
|
|
472
|
+
always_public: bool = False,
|
|
473
|
+
auth_str: Optional[str] = None,
|
|
474
|
+
):
|
|
475
|
+
self._client = client
|
|
476
|
+
self._created_time = created_time
|
|
477
|
+
self._init_params = {
|
|
478
|
+
"bucket_name": bucket_name,
|
|
479
|
+
"time_out": time_out,
|
|
480
|
+
"region": region,
|
|
481
|
+
"always_public": always_public,
|
|
482
|
+
"auth_str": auth_str,
|
|
483
|
+
}
|
|
484
|
+
self._auto_refresh = auth_str is None
|
|
485
|
+
|
|
486
|
+
def _check_and_refresh(self):
|
|
487
|
+
if not self._auto_refresh:
|
|
488
|
+
return
|
|
489
|
+
if time.time() - self._created_time >= self._ttl_seconds:
|
|
490
|
+
self._client = OSS_STS_API(**self._init_params)
|
|
491
|
+
self._created_time = time.time()
|
|
492
|
+
|
|
493
|
+
def __getattr__(self, name):
|
|
494
|
+
self._check_and_refresh()
|
|
495
|
+
return getattr(self._client, name)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
class OSSClientFactory:
|
|
499
|
+
"""OSS Client 工厂,创建带自动刷新的 OSS 客户端代理。"""
|
|
500
|
+
|
|
501
|
+
@staticmethod
|
|
502
|
+
def create_client(
|
|
503
|
+
bucket_name: str,
|
|
504
|
+
time_out: int = 60,
|
|
505
|
+
region: Optional[str] = None,
|
|
506
|
+
always_public: bool = False,
|
|
507
|
+
auth_str: Optional[str] = None,
|
|
508
|
+
) -> OSSClientProxy:
|
|
509
|
+
client = OSS_STS_API(
|
|
510
|
+
bucket_name=bucket_name,
|
|
511
|
+
time_out=time_out,
|
|
512
|
+
region=region,
|
|
513
|
+
always_public=always_public,
|
|
514
|
+
auth_str=auth_str,
|
|
515
|
+
)
|
|
516
|
+
return OSSClientProxy(
|
|
517
|
+
client=client,
|
|
518
|
+
created_time=time.time(),
|
|
519
|
+
bucket_name=bucket_name,
|
|
520
|
+
time_out=time_out,
|
|
521
|
+
region=region,
|
|
522
|
+
always_public=always_public,
|
|
523
|
+
auth_str=auth_str,
|
|
524
|
+
)
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Author: rxu
|
|
5
|
+
Date: 2024-07-17 16:38
|
|
6
|
+
Short Description: URL处理工具模块(从 peutils 迁移至 pewutils)
|
|
7
|
+
|
|
8
|
+
Change History:
|
|
9
|
+
2024-07-17: 初始版本(peutils)
|
|
10
|
+
2026-07-29: 迁移至 pewutils,适配 windmill 脚本平台;精简低频函数
|
|
11
|
+
|
|
12
|
+
模块特点:仅依赖 Python 标准库,无第三方依赖。
|
|
13
|
+
聚焦 windmill 脚本场景:OSS/存储 URL 解析、签名 URL 清理、查询参数操作。
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import urllib.parse
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ----------------------------------------------------------------------------
|
|
20
|
+
# 存储系统 URL(oss://, obs://, s3://, appen://)
|
|
21
|
+
# ----------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
_STORAGE_PREFIXES = ('oss://', 'obs://', 's3://', 'appen://')
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_storage_url(url: str) -> bool:
|
|
27
|
+
"""
|
|
28
|
+
检查是否为存储系统URL (oss://, obs://, s3://, appen://)
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
>>> is_storage_url("oss://bucket/key")
|
|
32
|
+
True
|
|
33
|
+
>>> is_storage_url("https://bucket/key")
|
|
34
|
+
False
|
|
35
|
+
"""
|
|
36
|
+
if not isinstance(url, str):
|
|
37
|
+
return False
|
|
38
|
+
return url.startswith(_STORAGE_PREFIXES)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parse_storage_url(storage_url: str) -> tuple[str, str]:
|
|
42
|
+
"""
|
|
43
|
+
解析存储URL,返回(bucket, path)
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
storage_url (str): 存储URL (如: oss://bucket-name/path/to/file)
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
tuple[str, str]: (bucket_name, path) 元组,path 可能为空字符串
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ValueError: 当URL格式不正确时
|
|
53
|
+
|
|
54
|
+
Example:
|
|
55
|
+
>>> parse_storage_url("oss://my-bucket/path/file.txt")
|
|
56
|
+
('my-bucket', 'path/file.txt')
|
|
57
|
+
>>> parse_storage_url("oss://my-bucket/")
|
|
58
|
+
('my-bucket', '')
|
|
59
|
+
"""
|
|
60
|
+
if not isinstance(storage_url, str):
|
|
61
|
+
raise ValueError("存储URL必须是字符串")
|
|
62
|
+
if not is_storage_url(storage_url):
|
|
63
|
+
raise ValueError("不是有效的存储URL格式")
|
|
64
|
+
|
|
65
|
+
# 移除协议前缀
|
|
66
|
+
for prefix in _STORAGE_PREFIXES:
|
|
67
|
+
if storage_url.startswith(prefix):
|
|
68
|
+
path_without_prefix = storage_url[len(prefix):]
|
|
69
|
+
break
|
|
70
|
+
|
|
71
|
+
# 分割bucket和路径
|
|
72
|
+
parts = path_without_prefix.split('/', 1)
|
|
73
|
+
if len(parts) == 1:
|
|
74
|
+
return parts[0], ""
|
|
75
|
+
bucket_name, path = parts
|
|
76
|
+
return bucket_name, path
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def parse_oss_full_path(oss_full_path: str) -> tuple[str, str]:
|
|
80
|
+
"""
|
|
81
|
+
解析 oss:// 路径,要求以 / 结尾,bucket/path 非空
|
|
82
|
+
|
|
83
|
+
与 parse_storage_url 的区别:强约束,适合脚本入参校验场景。
|
|
84
|
+
- 要求路径必须以 / 结尾(避免把文件当目录处理)
|
|
85
|
+
- bucket 和 path 都不能为空
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
oss_full_path (str): OSS 目录路径,如 "oss://bucket/folder/"
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
tuple[str, str]: (bucket_name, oss_path),oss_path 保留结尾的 "/"
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
AssertionError: 当路径不以 oss:// 开头、不以 / 结尾,或 bucket/path 为空
|
|
95
|
+
|
|
96
|
+
Example:
|
|
97
|
+
>>> parse_oss_full_path("oss://my-bucket/folder/")
|
|
98
|
+
('my-bucket', 'folder/')
|
|
99
|
+
"""
|
|
100
|
+
oss_full_path = oss_full_path.strip() # 去掉前后空格
|
|
101
|
+
assert oss_full_path.startswith("oss://"), "路径必须以oss://开始"
|
|
102
|
+
assert oss_full_path.endswith("/"), "路径必须以/结尾"
|
|
103
|
+
bucket_name, oss_path = parse_storage_url(oss_full_path)
|
|
104
|
+
assert bucket_name != '', "bucket不能为空,请检查"
|
|
105
|
+
assert oss_path != '', "路径不能为空,请检查"
|
|
106
|
+
return bucket_name, oss_path
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ----------------------------------------------------------------------------
|
|
110
|
+
# HTTP/HTTPS URL 清理与查询参数
|
|
111
|
+
# ----------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def get_clean_url(url: str) -> str:
|
|
114
|
+
"""
|
|
115
|
+
获取纯净url(移除查询参数并解码百分号)
|
|
116
|
+
|
|
117
|
+
典型场景:处理 OSS 临时签名 URL,去掉 Expires/OSSAccessKeyId/Signature 等参数,
|
|
118
|
+
并把 %2F 等解码回正常字符。
|
|
119
|
+
|
|
120
|
+
Example:
|
|
121
|
+
>>> get_clean_url("https://bucket.aliyuncs.com/path%2Ffile.pcd?Expires=123&Signature=xxx")
|
|
122
|
+
'https://bucket.aliyuncs.com/path/file.pcd'
|
|
123
|
+
"""
|
|
124
|
+
if not isinstance(url, str):
|
|
125
|
+
raise ValueError("URL必须是字符串类型")
|
|
126
|
+
if not url.strip():
|
|
127
|
+
return ""
|
|
128
|
+
|
|
129
|
+
url = urllib.parse.unquote(url)
|
|
130
|
+
parsed_url = urllib.parse.urlparse(url)
|
|
131
|
+
return urllib.parse.urlunparse(parsed_url._replace(query=""))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def is_private_url(url: str) -> bool:
|
|
135
|
+
"""
|
|
136
|
+
判断是否为私有访问URL(含 OSS/AWS 签名参数)
|
|
137
|
+
|
|
138
|
+
Example:
|
|
139
|
+
>>> is_private_url("http://example.com/file.txt?Expires=123&OSSAccessKeyId=abc")
|
|
140
|
+
True
|
|
141
|
+
>>> is_private_url("http://example.com/file.txt")
|
|
142
|
+
False
|
|
143
|
+
"""
|
|
144
|
+
if not isinstance(url, str):
|
|
145
|
+
return False
|
|
146
|
+
private_indicators = ('Expires=', 'OSSAccessKeyId=', 'Signature=', 'AWSAccessKeyId=')
|
|
147
|
+
return any(indicator in url for indicator in private_indicators)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def extract_query_params(url: str) -> dict[str, list[str]]:
|
|
151
|
+
"""
|
|
152
|
+
提取URL中的查询参数为字典
|
|
153
|
+
|
|
154
|
+
Example:
|
|
155
|
+
>>> extract_query_params("http://example.com?a=1&b=2&a=3")
|
|
156
|
+
{'a': ['1', '3'], 'b': ['2']}
|
|
157
|
+
"""
|
|
158
|
+
if not isinstance(url, str):
|
|
159
|
+
return {}
|
|
160
|
+
parsed = urllib.parse.urlparse(url)
|
|
161
|
+
return urllib.parse.parse_qs(parsed.query)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def add_query_params(url: str, params: dict[str, str | list[str]]) -> str:
|
|
165
|
+
"""
|
|
166
|
+
向URL添加查询参数,已存在的参数会合并
|
|
167
|
+
|
|
168
|
+
Example:
|
|
169
|
+
>>> add_query_params("http://example.com?a=1", {'b': '2'})
|
|
170
|
+
'http://example.com?a=1&b=2'
|
|
171
|
+
>>> add_query_params("http://example.com?a=1", {'a': '2'})
|
|
172
|
+
'http://example.com?a=1&a=2'
|
|
173
|
+
"""
|
|
174
|
+
if not isinstance(url, str) or not isinstance(params, dict):
|
|
175
|
+
return url
|
|
176
|
+
|
|
177
|
+
parsed = urllib.parse.urlparse(url)
|
|
178
|
+
existing_params = urllib.parse.parse_qs(parsed.query)
|
|
179
|
+
|
|
180
|
+
# 合并参数
|
|
181
|
+
for key, value in params.items():
|
|
182
|
+
new_values = value if isinstance(value, list) else [str(value)]
|
|
183
|
+
new_values = [str(v) for v in new_values]
|
|
184
|
+
if key in existing_params:
|
|
185
|
+
existing_params[key].extend(new_values)
|
|
186
|
+
else:
|
|
187
|
+
existing_params[key] = new_values
|
|
188
|
+
|
|
189
|
+
new_query = urllib.parse.urlencode(existing_params, doseq=True)
|
|
190
|
+
return urllib.parse.urlunparse(parsed._replace(query=new_query))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ----------------------------------------------------------------------------
|
|
194
|
+
# URL 类型判断与路径提取
|
|
195
|
+
# ----------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
def is_http_url(url: str) -> bool:
|
|
198
|
+
"""检查是否为HTTP/HTTPS协议的URL"""
|
|
199
|
+
if not isinstance(url, str):
|
|
200
|
+
return False
|
|
201
|
+
parsed = urllib.parse.urlparse(url)
|
|
202
|
+
return parsed.scheme.lower() in ('http', 'https')
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def get_relative_path(url: str) -> str:
|
|
206
|
+
"""
|
|
207
|
+
获取URL中域名或bucket后的相对路径(去掉开头斜杠)
|
|
208
|
+
|
|
209
|
+
支持HTTP/HTTPS URL和存储系统URL。
|
|
210
|
+
|
|
211
|
+
Examples:
|
|
212
|
+
>>> get_relative_path("https://appen-data.aliyun.com/xc/occ/anno.json")
|
|
213
|
+
'xc/occ/anno.json'
|
|
214
|
+
>>> get_relative_path("appen://appen-data/xc/occ/anno.json")
|
|
215
|
+
'xc/occ/anno.json'
|
|
216
|
+
>>> get_relative_path("oss://my-bucket/folder/file.txt")
|
|
217
|
+
'folder/file.txt'
|
|
218
|
+
"""
|
|
219
|
+
if not isinstance(url, str) or not url.strip():
|
|
220
|
+
return ""
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
if is_storage_url(url):
|
|
224
|
+
_, path = parse_storage_url(url)
|
|
225
|
+
return path
|
|
226
|
+
if is_http_url(url):
|
|
227
|
+
parsed = urllib.parse.urlparse(url)
|
|
228
|
+
return parsed.path.lstrip('/')
|
|
229
|
+
return ""
|
|
230
|
+
except Exception:
|
|
231
|
+
return ""
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
wmill_util - Windmill 平台专有工具
|
|
5
|
+
|
|
6
|
+
提供与 windmill runtime(wmill SDK / S3Object)交互的封装。
|
|
7
|
+
本地开发时若不调用相关方法,模块导入不会触发 wmill 加载。
|
|
8
|
+
|
|
9
|
+
Change History:
|
|
10
|
+
2026-07-29: 从 wind_scripts/ropedia-video-after-parse-11245.py 迁移 s3_output
|
|
11
|
+
重命名为 zip_to_s3;修复 suffix 不带点时匹配失败的 bug
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import io
|
|
15
|
+
import logging
|
|
16
|
+
import uuid as _uuid
|
|
17
|
+
import zipfile
|
|
18
|
+
from contextlib import contextmanager
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Optional, Union, List
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
__all__ = ["zip_to_s3"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _gen_zip_key(prefix: str) -> str:
|
|
29
|
+
"""生成 S3 key:{prefix}/{YYYYMMDD}/{uuid}.zip"""
|
|
30
|
+
date_str = datetime.now(timezone.utc).strftime("%Y%m%d")
|
|
31
|
+
return f"{prefix}/{date_str}/{_uuid.uuid4()}.zip"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _normalize_suffixes(suffix: Union[str, List[str], None]) -> Union[set, None]:
|
|
35
|
+
"""统一 suffix 为带点的集合:'json' / '.json' / ['json', '.txt'] → {'.json', '.txt'}"""
|
|
36
|
+
if suffix is None:
|
|
37
|
+
return None
|
|
38
|
+
if isinstance(suffix, str):
|
|
39
|
+
suffix = [suffix]
|
|
40
|
+
return {s if s.startswith(".") else f".{s}" for s in suffix}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@contextmanager
|
|
44
|
+
def zip_to_s3(
|
|
45
|
+
key_prefix: str = "zip_files",
|
|
46
|
+
suffix: Optional[Union[str, List[str]]] = None,
|
|
47
|
+
):
|
|
48
|
+
"""
|
|
49
|
+
上下文管理器:跟踪 with 块内新增的本地文件,退出时打包成 zip 上传到 Windmill S3。
|
|
50
|
+
S3 key 自动生成为:{key_prefix}/{YYYYMMDD}/{uuid}.zip,无需手动指定。
|
|
51
|
+
|
|
52
|
+
:param key_prefix: S3 key 前缀
|
|
53
|
+
:param suffix: 仅打包指定后缀的新文件;可为 str 或 list。
|
|
54
|
+
'json' 与 '.json' 均可(自动补点)
|
|
55
|
+
|
|
56
|
+
用法::
|
|
57
|
+
|
|
58
|
+
with zip_to_s3("output/zip", suffix=".json") as out:
|
|
59
|
+
Path("a.json").write_text("...")
|
|
60
|
+
# 退出 with 时打包 → 上传,out.result 为 S3Object,out.files 为本地文件列表
|
|
61
|
+
|
|
62
|
+
本地开发若未安装 wmill,调用本函数会抛 ImportError。
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
import wmill
|
|
66
|
+
from wmill import S3Object
|
|
67
|
+
except ImportError:
|
|
68
|
+
raise ImportError(
|
|
69
|
+
"wmill 未安装。windmill 脚本运行时无需手动安装;"
|
|
70
|
+
"本地开发请运行: pip install wmill"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
suffixes = _normalize_suffixes(suffix)
|
|
74
|
+
|
|
75
|
+
watch_dir = Path.cwd()
|
|
76
|
+
existing = {p.name for p in watch_dir.iterdir() if p.is_file()}
|
|
77
|
+
|
|
78
|
+
class _Result:
|
|
79
|
+
def __init__(self):
|
|
80
|
+
self.result = None # Optional[S3Object],运行时赋值
|
|
81
|
+
self.files: List[Path] = []
|
|
82
|
+
|
|
83
|
+
out = _Result()
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
yield out
|
|
87
|
+
finally:
|
|
88
|
+
new_files = [
|
|
89
|
+
p for p in watch_dir.iterdir()
|
|
90
|
+
if p.is_file()
|
|
91
|
+
and p.name not in existing
|
|
92
|
+
and (suffixes is None or p.suffix in suffixes)
|
|
93
|
+
]
|
|
94
|
+
out.files = new_files
|
|
95
|
+
|
|
96
|
+
if not new_files:
|
|
97
|
+
logger.warning("zip_to_s3: 没有新增文件可上传")
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
buf = io.BytesIO()
|
|
101
|
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
102
|
+
for f in new_files:
|
|
103
|
+
zf.write(f, f.name)
|
|
104
|
+
s3_key = _gen_zip_key(key_prefix)
|
|
105
|
+
obj = S3Object(s3=s3_key)
|
|
106
|
+
wmill.write_s3_file(obj, buf.getvalue())
|
|
107
|
+
out.result = obj
|
|
108
|
+
logger.info(f"zip_to_s3: 打包 {len(new_files)} 个文件 → {s3_key}")
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pewutils
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Lightweight utils for windmill scripts (migrated from peutils).
|
|
5
|
+
Author-email: rxu <rxu@appen.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://gitee.com/yunsansheng/pewutils
|
|
8
|
+
Keywords: pewutils,windmill,pe
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: oss2>=2.15
|
|
18
|
+
Requires-Dist: requests>=2.25
|
|
19
|
+
|
|
20
|
+
# pewutils
|
|
21
|
+
|
|
22
|
+
PE Windmill Utils —— 为 Windmill 脚本平台提供的轻量级 Python 工具包。
|
|
23
|
+
|
|
24
|
+
由 `peutils` 迁移重构而来,适配 windmill 脚本平台。
|
|
25
|
+
|
|
26
|
+
## 已迁移模块
|
|
27
|
+
|
|
28
|
+
| 模块 | 说明 | 依赖 |
|
|
29
|
+
|------|------|------|
|
|
30
|
+
| `url_util` | URL 处理工具(解析、存储 URL、签名 URL 清理、查询参数等) | 仅标准库 |
|
|
31
|
+
| `oss_util` | 阿里云 OSS 工具(STS 鉴权、列举、复制、搜索) | oss2, requests |
|
|
32
|
+
| `wmill_util` | Windmill 平台工具(zip 打包上传 S3) | wmill(windmill runtime 内置) |
|
|
33
|
+
|
|
34
|
+
## 安装
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# 核心包(含 oss2/requests 依赖)
|
|
38
|
+
pip install pewutils
|
|
39
|
+
|
|
40
|
+
# 本地开发(editable)
|
|
41
|
+
git clone <repo-url> pewutils
|
|
42
|
+
cd pewutils
|
|
43
|
+
pip install -e .
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## 使用示例
|
|
47
|
+
|
|
48
|
+
### URL 工具
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from pewutils.url_util import (
|
|
52
|
+
get_clean_url,
|
|
53
|
+
parse_storage_url,
|
|
54
|
+
parse_oss_full_path,
|
|
55
|
+
get_relative_path,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# 去除 OSS 签名 URL 的查询参数
|
|
59
|
+
clean = get_clean_url("https://bucket.aliyuncs.com/path/file.pcd?Expires=123&Signature=xxx")
|
|
60
|
+
# -> "https://bucket.aliyuncs.com/path/file.pcd"
|
|
61
|
+
|
|
62
|
+
# 解析存储 URL(宽松)
|
|
63
|
+
bucket, path = parse_storage_url("oss://my-bucket/folder/file.txt")
|
|
64
|
+
# -> ("my-bucket", "folder/file.txt")
|
|
65
|
+
|
|
66
|
+
# 解析 OSS 目录路径(强约束,要求以 / 结尾)
|
|
67
|
+
bucket, folder = parse_oss_full_path("oss://my-bucket/folder/")
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### OSS 工具
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from pewutils.oss_util import OSSClientFactory
|
|
74
|
+
|
|
75
|
+
# 创建带自动刷新的 OSS client(auth_str 12h 过期,11h 自动刷新)
|
|
76
|
+
client = OSSClientFactory.create_client(bucket_name="my-bucket")
|
|
77
|
+
|
|
78
|
+
# 读取 OSS 对象为字节流
|
|
79
|
+
data = client.read_bytes("path/to/file.pcd")
|
|
80
|
+
|
|
81
|
+
# 列举目录下所有文件
|
|
82
|
+
files = client.list_bucket_files_deep("path/to/folder/")
|
|
83
|
+
|
|
84
|
+
# BFS 查找最浅的目标文件
|
|
85
|
+
path = client.find_shallowest_target("root/", "target.json", "file")
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Windmill 平台工具
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from pewutils.wmill_util import zip_to_s3
|
|
92
|
+
|
|
93
|
+
# with 块内生成文件,退出时自动打包 zip 上传到 Windmill S3
|
|
94
|
+
with zip_to_s3("output/zip", suffix=".json") as out:
|
|
95
|
+
Path("a.json").write_text('{"k": 1}')
|
|
96
|
+
Path("b.json").write_text('{"k": 2}')
|
|
97
|
+
# out.result -> S3Object(s3="output/zip/20260729/uuid.zip")
|
|
98
|
+
# out.files -> [Path("a.json"), Path("b.json")]
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## 目录结构
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
pewutils/
|
|
105
|
+
├── README.md
|
|
106
|
+
├── pyproject.toml # 打包配置(PEP 517/518,requires-python>=3.10)
|
|
107
|
+
├── .gitignore
|
|
108
|
+
└── pewutils/
|
|
109
|
+
├── __init__.py # 默认导出 url_util
|
|
110
|
+
├── url_util.py # URL 处理(纯标准库)
|
|
111
|
+
├── oss_util.py # OSS 工具(oss2, requests)
|
|
112
|
+
└── wmill_util.py # Windmill 平台工具(wmill lazy import)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## 构建
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
pip install build
|
|
119
|
+
python -m build
|
|
120
|
+
# 产物:dist/pewutils-<version>-py3-none-any.whl
|
|
121
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
pewutils/__init__.py
|
|
4
|
+
pewutils/oss_util.py
|
|
5
|
+
pewutils/url_util.py
|
|
6
|
+
pewutils/wmill_util.py
|
|
7
|
+
pewutils.egg-info/PKG-INFO
|
|
8
|
+
pewutils.egg-info/SOURCES.txt
|
|
9
|
+
pewutils.egg-info/dependency_links.txt
|
|
10
|
+
pewutils.egg-info/requires.txt
|
|
11
|
+
pewutils.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pewutils
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pewutils"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "Lightweight utils for windmill scripts (migrated from peutils)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "rxu", email = "rxu@appen.com" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["pewutils", "windmill", "pe"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
]
|
|
24
|
+
# 默认安装核心依赖(轻量)
|
|
25
|
+
dependencies = [
|
|
26
|
+
"oss2>=2.15",
|
|
27
|
+
"requests>=2.25",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
# 可选重依赖:按需安装
|
|
31
|
+
# pcd = ["pypcd4>=1.0", "plyfile>=0.7.4", "open3d>=0.19"]
|
|
32
|
+
# image = ["opencv-python", "Pillow", "numpy"]
|
|
33
|
+
# excel = ["openpyxl", "pandas"]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://gitee.com/yunsansheng/pewutils"
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.packages.find]
|
|
39
|
+
include = ["pewutils*"]
|
pewutils-0.0.1/setup.cfg
ADDED