sdpy-kit 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.
kit/file_kit.py ADDED
@@ -0,0 +1,429 @@
1
+ """
2
+ Copyright (c) 2021-2026 Clark Chang. All Rights Reserved.
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+
16
+ Project: sdpy
17
+ Author: Clark Chang
18
+ """
19
+
20
+ # date: 2026-08-22
21
+ """文件工具类,提供文件读写、移动、复制等跨平台操作功能
22
+
23
+ 重构说明(破坏性内部、外部零风险):
24
+ - 用 `_ROOT_PATH is None` 语义替代 `_INITIALIZED` 布尔标记,收敛初始化逻辑。
25
+ - 修正 `_get_root_path` 返回类型(不再返回可能为 None 的值却标注为 Path)。
26
+ - 补全 `walk_dir` 缺失的返回类型标注。
27
+ - 公开方法名、签名与语义不变。
28
+ """
29
+
30
+ from collections.abc import Iterator
31
+ from pathlib import Path
32
+ from shutil import copy2, copytree, move, rmtree
33
+
34
+ from kit.config.kit_config import get_settings
35
+ from kit.config.log_config import get_default_logger
36
+
37
+ settings = get_settings()
38
+ logger = get_default_logger(__name__)
39
+
40
+
41
+ class FileKit:
42
+ """文件工具类,提供文件读写、移动、复制等跨平台操作功能
43
+
44
+ 配置:
45
+ - 根文件存储路径通过配置文件 FILE_STORAGE_ROOT 获取
46
+ - 使用统一的 get_settings() 进行配置管理
47
+ - 使用统一的 get_default_logger() 进行日志记录
48
+ - 支持跨平台路径处理(Windows/Linux)
49
+
50
+ 所有方法均处理路径分隔符,确保跨平台兼容性。
51
+ """
52
+
53
+ # None 表示尚未初始化(未调用 configure,也未被惰性初始化)
54
+ _ROOT_PATH: Path | None = None
55
+
56
+ @classmethod
57
+ def _ensure_initialized(cls) -> None:
58
+ """惰性初始化根文件存储路径(首次使用时从配置读取)"""
59
+ if cls._ROOT_PATH is not None:
60
+ return
61
+
62
+ root_path_str = settings.FILE_STORAGE_ROOT
63
+ if not root_path_str:
64
+ raise RuntimeError("配置项 FILE_STORAGE_ROOT 未设置,请设置文件存储根路径")
65
+
66
+ root_path = cls._normalize_path(root_path_str)
67
+
68
+ if not root_path.exists():
69
+ raise RuntimeError(f"根路径不存在: {root_path}")
70
+ if not root_path.is_dir():
71
+ raise RuntimeError(f"根路径不是有效目录: {root_path}")
72
+
73
+ cls._ROOT_PATH = root_path
74
+ logger.info(f"✅ FileKit 已初始化,根路径: {root_path}")
75
+
76
+ @staticmethod
77
+ def _normalize_path(path: str) -> Path:
78
+ """规范化路径,处理跨平台路径分隔符"""
79
+ return Path(path).resolve()
80
+
81
+ @classmethod
82
+ def configure(cls, root_path: str) -> None:
83
+ """手动配置根文件存储路径
84
+
85
+ Args:
86
+ root_path: 文件存储根路径
87
+
88
+ Raises:
89
+ ValueError: 路径不存在或不是目录时抛出
90
+ """
91
+ normalized = cls._normalize_path(root_path)
92
+
93
+ if not normalized.exists():
94
+ raise ValueError(f"根路径不存在: {normalized}")
95
+ if not normalized.is_dir():
96
+ raise ValueError(f"根路径不是有效目录: {normalized}")
97
+
98
+ cls._ROOT_PATH = normalized
99
+
100
+ @classmethod
101
+ def read_file(cls, relative_path: str, encoding: str = "utf-8") -> str:
102
+ """读取文件内容
103
+
104
+ Args:
105
+ relative_path: 相对于根路径的文件路径
106
+ encoding: 文件编码,默认 utf-8
107
+
108
+ Returns:
109
+ str: 文件内容字符串
110
+ """
111
+ cls._ensure_initialized()
112
+
113
+ full_path = cls._get_full_path(relative_path)
114
+
115
+ if not full_path.exists():
116
+ raise FileNotFoundError(f"文件不存在: {full_path}")
117
+
118
+ try:
119
+ return full_path.read_text(encoding=encoding)
120
+ except UnicodeDecodeError as e:
121
+ raise UnicodeDecodeError(
122
+ e.encoding,
123
+ e.object,
124
+ e.start,
125
+ e.end,
126
+ f"文件编码错误,请尝试使用其他编码读取: {e}",
127
+ )
128
+
129
+ @classmethod
130
+ def write_file(
131
+ cls,
132
+ relative_path: str,
133
+ content: str,
134
+ mode: str = "overwrite",
135
+ encoding: str = "utf-8",
136
+ ) -> bool:
137
+ """写入文件内容
138
+
139
+ Args:
140
+ relative_path: 相对于根路径的文件路径
141
+ content: 要写入的内容
142
+ mode: 写入模式,"overwrite"(覆盖)或 "append"(追加)
143
+ encoding: 文件编码,默认 utf-8
144
+
145
+ Returns:
146
+ bool: 写入成功返回 True
147
+ """
148
+ if mode not in ("overwrite", "append"):
149
+ raise ValueError(
150
+ f"无效的写入模式: {mode},支持的模式: 'overwrite', 'append'"
151
+ )
152
+
153
+ cls._ensure_initialized()
154
+
155
+ full_path = cls._get_full_path(relative_path)
156
+ parent_dir = full_path.parent
157
+
158
+ if not parent_dir.exists():
159
+ parent_dir.mkdir(parents=True, exist_ok=True)
160
+
161
+ try:
162
+ if mode == "append":
163
+ with full_path.open("a", encoding=encoding) as f:
164
+ f.write(content)
165
+ else:
166
+ full_path.write_text(content, encoding=encoding)
167
+ return True
168
+ except Exception as e:
169
+ raise RuntimeError(f"写入文件失败: {e}")
170
+
171
+ @classmethod
172
+ def move_file(cls, src_relative_path: str, dest_relative_path: str) -> bool:
173
+ """移动文件(重命名)
174
+
175
+ Args:
176
+ src_relative_path: 源文件相对于根路径的路径
177
+ dest_relative_path: 目标文件相对于根路径的路径
178
+
179
+ Returns:
180
+ bool: 移动成功返回 True
181
+ """
182
+ cls._ensure_initialized()
183
+
184
+ src_path = cls._get_full_path(src_relative_path)
185
+ dest_path = cls._get_full_path(dest_relative_path)
186
+
187
+ if not src_path.exists():
188
+ raise FileNotFoundError(f"源文件不存在: {src_path}")
189
+
190
+ dest_parent = dest_path.parent
191
+ if dest_parent and not dest_parent.exists():
192
+ dest_parent.mkdir(parents=True, exist_ok=True)
193
+
194
+ try:
195
+ move(src_path, dest_path)
196
+ return True
197
+ except Exception as e:
198
+ raise RuntimeError(f"移动文件失败: {e}")
199
+
200
+ @classmethod
201
+ def copy_file(cls, src_relative_path: str, dest_relative_path: str) -> bool:
202
+ """复制文件
203
+
204
+ Args:
205
+ src_relative_path: 源文件相对于根路径的路径
206
+ dest_relative_path: 目标文件相对于根路径的路径
207
+
208
+ Returns:
209
+ bool: 复制成功返回 True
210
+ """
211
+ cls._ensure_initialized()
212
+
213
+ src_path = cls._get_full_path(src_relative_path)
214
+ dest_path = cls._get_full_path(dest_relative_path)
215
+
216
+ if not src_path.exists():
217
+ raise FileNotFoundError(f"源文件不存在: {src_path}")
218
+
219
+ dest_parent = dest_path.parent
220
+ if dest_parent and not dest_parent.exists():
221
+ dest_parent.mkdir(parents=True, exist_ok=True)
222
+
223
+ try:
224
+ copy2(src_path, dest_path)
225
+ return True
226
+ except Exception as e:
227
+ raise RuntimeError(f"复制文件失败: {e}")
228
+
229
+ @classmethod
230
+ def delete_file(cls, relative_path: str) -> bool:
231
+ """删除文件
232
+
233
+ Args:
234
+ relative_path: 要删除的文件相对于根路径的路径
235
+
236
+ Returns:
237
+ bool: 删除成功返回 True
238
+ """
239
+ cls._ensure_initialized()
240
+
241
+ full_path = cls._get_full_path(relative_path)
242
+
243
+ if not full_path.exists():
244
+ raise FileNotFoundError(f"文件不存在: {full_path}")
245
+
246
+ try:
247
+ full_path.unlink()
248
+ return True
249
+ except Exception as e:
250
+ raise RuntimeError(f"删除文件失败: {e}")
251
+
252
+ @classmethod
253
+ def file_exists(cls, relative_path: str) -> bool:
254
+ """检查文件是否存在"""
255
+ cls._ensure_initialized()
256
+
257
+ full_path = cls._get_full_path(relative_path)
258
+ return full_path.is_file()
259
+
260
+ @classmethod
261
+ def get_full_path(cls, relative_path: str) -> Path:
262
+ """获取文件的完整路径"""
263
+ cls._ensure_initialized()
264
+ return cls._get_full_path(relative_path)
265
+
266
+ @classmethod
267
+ def _get_full_path(cls, relative_path: str) -> Path:
268
+ """内部方法:获取完整路径"""
269
+ assert cls._ROOT_PATH is not None
270
+ return cls._ROOT_PATH / relative_path
271
+
272
+ @classmethod
273
+ def get_root_path(cls) -> str:
274
+ """获取当前配置的根路径"""
275
+ return str(cls._get_root_path())
276
+
277
+ @classmethod
278
+ def _get_root_path(cls) -> Path:
279
+ """获取根文件存储路径"""
280
+ cls._ensure_initialized()
281
+ assert cls._ROOT_PATH is not None
282
+ return cls._ROOT_PATH
283
+
284
+ @staticmethod
285
+ def generate_unique_id() -> str:
286
+ """生成唯一 ID(字符串类型)"""
287
+ from kit.common_kit import CommonKit
288
+
289
+ return CommonKit.generate_timestamp_id()
290
+
291
+ @staticmethod
292
+ def generate_filename(prefix: str = "", extension: str = "") -> str:
293
+ """生成带有时间戳的唯一文件名
294
+
295
+ Args:
296
+ prefix: 文件名前缀(可选),如 "log_"
297
+ extension: 文件扩展名(可选),如 "txt",可不带点
298
+
299
+ Returns:
300
+ str: 生成的文件名
301
+ """
302
+ from kit.common_kit import CommonKit
303
+
304
+ unique_id = CommonKit.generate_timestamp_id()
305
+ if extension:
306
+ if not extension.startswith("."):
307
+ extension = "." + extension
308
+ return f"{prefix}{unique_id}{extension}"
309
+ return f"{prefix}{unique_id}"
310
+
311
+ @classmethod
312
+ def move_dir(cls, src_relative_path: str, dest_relative_path: str) -> bool:
313
+ """移动目录
314
+
315
+ Args:
316
+ src_relative_path: 源目录相对于根路径的路径
317
+ dest_relative_path: 目标目录相对于根路径的路径
318
+
319
+ Returns:
320
+ bool: 移动成功返回 True
321
+ """
322
+ cls._ensure_initialized()
323
+
324
+ src_path = cls._get_full_path(src_relative_path)
325
+ dest_path = cls._get_full_path(dest_relative_path)
326
+
327
+ if not src_path.exists():
328
+ raise FileNotFoundError(f"源目录不存在: {src_path}")
329
+ if not src_path.is_dir():
330
+ raise ValueError(f"源路径不是目录: {src_path}")
331
+
332
+ try:
333
+ move(src_path, dest_path)
334
+ return True
335
+ except Exception as e:
336
+ raise RuntimeError(f"移动目录失败: {e}")
337
+
338
+ @classmethod
339
+ def delete_dir(cls, relative_path: str) -> bool:
340
+ """删除目录(递归删除)
341
+
342
+ Args:
343
+ relative_path: 要删除的目录相对于根路径的路径
344
+
345
+ Returns:
346
+ bool: 删除成功返回 True
347
+ """
348
+ cls._ensure_initialized()
349
+
350
+ full_path = cls._get_full_path(relative_path)
351
+
352
+ if not full_path.exists():
353
+ raise FileNotFoundError(f"目录不存在: {full_path}")
354
+ if not full_path.is_dir():
355
+ raise ValueError(f"路径不是目录: {full_path}")
356
+
357
+ try:
358
+ rmtree(full_path)
359
+ return True
360
+ except Exception as e:
361
+ raise RuntimeError(f"删除目录失败: {e}")
362
+
363
+ @classmethod
364
+ def copy_dir(cls, src_relative_path: str, dest_relative_path: str) -> bool:
365
+ """复制目录(递归复制)
366
+
367
+ Args:
368
+ src_relative_path: 源目录相对于根路径的路径
369
+ dest_relative_path: 目标目录相对于根路径的路径
370
+
371
+ Returns:
372
+ bool: 复制成功返回 True
373
+ """
374
+ cls._ensure_initialized()
375
+
376
+ src_path = cls._get_full_path(src_relative_path)
377
+ dest_path = cls._get_full_path(dest_relative_path)
378
+
379
+ if not src_path.exists():
380
+ raise FileNotFoundError(f"源目录不存在: {src_path}")
381
+ if not src_path.is_dir():
382
+ raise ValueError(f"源路径不是目录: {src_path}")
383
+
384
+ try:
385
+ copytree(src_path, dest_path)
386
+ return True
387
+ except Exception as e:
388
+ raise RuntimeError(f"复制目录失败: {e}")
389
+
390
+ @classmethod
391
+ def walk_dir(
392
+ cls,
393
+ relative_path: str = "",
394
+ top_down: bool = True,
395
+ on_error: str | None = None,
396
+ ) -> Iterator[tuple[Path, list[str], list[str]]]:
397
+ """遍历目录树(使用 Path.walk 方法)
398
+
399
+ Args:
400
+ relative_path: 要遍历的目录相对于根路径的路径,空字符串表示根目录
401
+ top_down: 遍历顺序,True 为自顶向下,False 为自底向上
402
+ on_error: 错误处理方式,None 或 "ignore" 或 "report"
403
+
404
+ Yields:
405
+ (root: Path, dirs: list[str], files: list[str]) 元组
406
+ """
407
+ cls._ensure_initialized()
408
+
409
+ full_path = cls._get_full_path(relative_path)
410
+ root_path = cls._get_root_path()
411
+
412
+ if not full_path.exists():
413
+ raise FileNotFoundError(f"目录不存在: {full_path}")
414
+ if not full_path.is_dir():
415
+ raise ValueError(f"路径不是目录: {full_path}")
416
+
417
+ def error_handler(os_error: OSError) -> None:
418
+ if on_error == "report":
419
+ raise os_error
420
+
421
+ for root, dirs, files in full_path.walk(
422
+ top_down=top_down,
423
+ on_error=error_handler if on_error in ("ignore", "report") else None,
424
+ ):
425
+ yield (
426
+ root.relative_to(root_path) if root != root_path else Path("."),
427
+ dirs,
428
+ files,
429
+ )