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.
@@ -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,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,2 @@
1
+ oss2>=2.15
2
+ requests>=2.25
@@ -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*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+