pyxllib 0.0.43__py3-none-any.whl → 0.3.197__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.
- pyxllib/__init__.py +9 -2
- pyxllib/algo/__init__.py +8 -0
- pyxllib/algo/disjoint.py +54 -0
- pyxllib/algo/geo.py +541 -0
- pyxllib/{util/mathlib.py → algo/intervals.py} +172 -36
- pyxllib/algo/matcher.py +389 -0
- pyxllib/algo/newbie.py +166 -0
- pyxllib/algo/pupil.py +629 -0
- pyxllib/algo/shapelylib.py +67 -0
- pyxllib/algo/specialist.py +241 -0
- pyxllib/algo/stat.py +494 -0
- pyxllib/algo/treelib.py +149 -0
- pyxllib/algo/unitlib.py +66 -0
- pyxllib/autogui/__init__.py +5 -0
- pyxllib/autogui/activewin.py +246 -0
- pyxllib/autogui/all.py +9 -0
- pyxllib/autogui/autogui.py +852 -0
- pyxllib/autogui/uiautolib.py +362 -0
- pyxllib/autogui/virtualkey.py +102 -0
- pyxllib/autogui/wechat.py +827 -0
- pyxllib/autogui/wechat_msg.py +421 -0
- pyxllib/autogui/wxautolib.py +84 -0
- pyxllib/cv/__init__.py +1 -11
- pyxllib/cv/expert.py +267 -0
- pyxllib/cv/{imlib.py → imfile.py} +18 -83
- pyxllib/cv/imhash.py +39 -0
- pyxllib/cv/pupil.py +9 -0
- pyxllib/cv/rgbfmt.py +1525 -0
- pyxllib/cv/slidercaptcha.py +137 -0
- pyxllib/cv/trackbartools.py +163 -49
- pyxllib/cv/xlcvlib.py +1040 -0
- pyxllib/cv/xlpillib.py +423 -0
- pyxllib/data/__init__.py +0 -0
- pyxllib/data/echarts.py +240 -0
- pyxllib/data/jsonlib.py +89 -0
- pyxllib/{util/oss2_.py → data/oss.py} +11 -9
- pyxllib/data/pglib.py +1127 -0
- pyxllib/data/sqlite.py +568 -0
- pyxllib/{util → data}/sqllib.py +13 -31
- pyxllib/ext/JLineViewer.py +505 -0
- pyxllib/ext/__init__.py +6 -0
- pyxllib/{util → ext}/demolib.py +119 -35
- pyxllib/ext/drissionlib.py +277 -0
- pyxllib/ext/kq5034lib.py +12 -0
- pyxllib/{util/main.py → ext/old.py} +122 -284
- pyxllib/ext/qt.py +449 -0
- pyxllib/ext/robustprocfile.py +497 -0
- pyxllib/ext/seleniumlib.py +76 -0
- pyxllib/{util/tklib.py → ext/tk.py} +10 -11
- pyxllib/ext/unixlib.py +827 -0
- pyxllib/ext/utools.py +351 -0
- pyxllib/{util/webhooklib.py → ext/webhook.py} +45 -17
- pyxllib/ext/win32lib.py +40 -0
- pyxllib/ext/wjxlib.py +88 -0
- pyxllib/ext/wpsapi.py +124 -0
- pyxllib/ext/xlwork.py +9 -0
- pyxllib/ext/yuquelib.py +1105 -0
- pyxllib/file/__init__.py +17 -0
- pyxllib/file/docxlib.py +761 -0
- pyxllib/{util → file}/gitlib.py +40 -27
- pyxllib/file/libreoffice.py +165 -0
- pyxllib/file/movielib.py +148 -0
- pyxllib/file/newbie.py +10 -0
- pyxllib/file/onenotelib.py +1469 -0
- pyxllib/file/packlib/__init__.py +330 -0
- pyxllib/{util → file/packlib}/zipfile.py +598 -195
- pyxllib/file/pdflib.py +426 -0
- pyxllib/file/pupil.py +185 -0
- pyxllib/file/specialist/__init__.py +685 -0
- pyxllib/{basic/_5_dirlib.py → file/specialist/dirlib.py} +364 -93
- pyxllib/file/specialist/download.py +193 -0
- pyxllib/file/specialist/filelib.py +2829 -0
- pyxllib/file/xlsxlib.py +3131 -0
- pyxllib/file/xlsyncfile.py +341 -0
- pyxllib/prog/__init__.py +5 -0
- pyxllib/prog/cachetools.py +64 -0
- pyxllib/prog/deprecatedlib.py +233 -0
- pyxllib/prog/filelock.py +42 -0
- pyxllib/prog/ipyexec.py +253 -0
- pyxllib/prog/multiprogs.py +940 -0
- pyxllib/prog/newbie.py +451 -0
- pyxllib/prog/pupil.py +1197 -0
- pyxllib/{sitepackages.py → prog/sitepackages.py} +5 -3
- pyxllib/prog/specialist/__init__.py +391 -0
- pyxllib/prog/specialist/bc.py +203 -0
- pyxllib/prog/specialist/browser.py +497 -0
- pyxllib/prog/specialist/common.py +347 -0
- pyxllib/prog/specialist/datetime.py +199 -0
- pyxllib/prog/specialist/tictoc.py +240 -0
- pyxllib/prog/specialist/xllog.py +180 -0
- pyxllib/prog/xlosenv.py +108 -0
- pyxllib/stdlib/__init__.py +17 -0
- pyxllib/{util → stdlib}/tablepyxl/__init__.py +1 -3
- pyxllib/{util → stdlib}/tablepyxl/style.py +1 -1
- pyxllib/{util → stdlib}/tablepyxl/tablepyxl.py +2 -4
- pyxllib/text/__init__.py +8 -0
- pyxllib/text/ahocorasick.py +39 -0
- pyxllib/text/airscript.js +744 -0
- pyxllib/text/charclasslib.py +121 -0
- pyxllib/text/jiebalib.py +267 -0
- pyxllib/text/jinjalib.py +32 -0
- pyxllib/text/jsa_ai_prompt.md +271 -0
- pyxllib/text/jscode.py +922 -0
- pyxllib/text/latex/__init__.py +158 -0
- pyxllib/text/levenshtein.py +303 -0
- pyxllib/text/nestenv.py +1215 -0
- pyxllib/text/newbie.py +300 -0
- pyxllib/text/pupil/__init__.py +8 -0
- pyxllib/text/pupil/common.py +1121 -0
- pyxllib/text/pupil/xlalign.py +326 -0
- pyxllib/text/pycode.py +47 -0
- pyxllib/text/specialist/__init__.py +8 -0
- pyxllib/text/specialist/common.py +112 -0
- pyxllib/text/specialist/ptag.py +186 -0
- pyxllib/text/spellchecker.py +172 -0
- pyxllib/text/templates/echart_base.html +11 -0
- pyxllib/text/templates/highlight_code.html +17 -0
- pyxllib/text/templates/latex_editor.html +103 -0
- pyxllib/text/vbacode.py +17 -0
- pyxllib/text/xmllib.py +747 -0
- pyxllib/xl.py +39 -0
- pyxllib/xlcv.py +17 -0
- pyxllib-0.3.197.dist-info/METADATA +48 -0
- pyxllib-0.3.197.dist-info/RECORD +126 -0
- {pyxllib-0.0.43.dist-info → pyxllib-0.3.197.dist-info}/WHEEL +4 -5
- pyxllib/basic/_1_strlib.py +0 -945
- pyxllib/basic/_2_timelib.py +0 -488
- pyxllib/basic/_3_pathlib.py +0 -916
- pyxllib/basic/_4_loglib.py +0 -419
- pyxllib/basic/__init__.py +0 -54
- pyxllib/basic/arrow_.py +0 -250
- pyxllib/basic/chardet_.py +0 -66
- pyxllib/basic/dirlib.py +0 -529
- pyxllib/basic/dprint.py +0 -202
- pyxllib/basic/extension.py +0 -12
- pyxllib/basic/judge.py +0 -31
- pyxllib/basic/log.py +0 -204
- pyxllib/basic/pathlib_.py +0 -705
- pyxllib/basic/pytictoc.py +0 -102
- pyxllib/basic/qiniu_.py +0 -61
- pyxllib/basic/strlib.py +0 -761
- pyxllib/basic/timer.py +0 -132
- pyxllib/cv/cv.py +0 -834
- pyxllib/cv/cvlib/_1_geo.py +0 -543
- pyxllib/cv/cvlib/_2_cvprcs.py +0 -309
- pyxllib/cv/cvlib/_2_imgproc.py +0 -594
- pyxllib/cv/cvlib/_3_pilprcs.py +0 -80
- pyxllib/cv/cvlib/_4_cvimg.py +0 -211
- pyxllib/cv/cvlib/__init__.py +0 -10
- pyxllib/cv/debugtools.py +0 -82
- pyxllib/cv/fitz_.py +0 -300
- pyxllib/cv/installer.py +0 -42
- pyxllib/debug/_0_installer.py +0 -38
- pyxllib/debug/_1_typelib.py +0 -277
- pyxllib/debug/_2_chrome.py +0 -198
- pyxllib/debug/_3_showdir.py +0 -161
- pyxllib/debug/_4_bcompare.py +0 -140
- pyxllib/debug/__init__.py +0 -49
- pyxllib/debug/bcompare.py +0 -132
- pyxllib/debug/chrome.py +0 -198
- pyxllib/debug/installer.py +0 -38
- pyxllib/debug/showdir.py +0 -158
- pyxllib/debug/typelib.py +0 -278
- pyxllib/image/__init__.py +0 -12
- pyxllib/torch/__init__.py +0 -20
- pyxllib/torch/modellib.py +0 -37
- pyxllib/torch/trainlib.py +0 -344
- pyxllib/util/__init__.py +0 -20
- pyxllib/util/aip_.py +0 -141
- pyxllib/util/casiadb.py +0 -59
- pyxllib/util/excellib.py +0 -495
- pyxllib/util/filelib.py +0 -612
- pyxllib/util/jsondata.py +0 -27
- pyxllib/util/jsondata2.py +0 -92
- pyxllib/util/labelmelib.py +0 -139
- pyxllib/util/onepy/__init__.py +0 -29
- pyxllib/util/onepy/onepy.py +0 -574
- pyxllib/util/onepy/onmanager.py +0 -170
- pyxllib/util/pyautogui_.py +0 -219
- pyxllib/util/textlib.py +0 -1305
- pyxllib/util/unorder.py +0 -22
- pyxllib/util/xmllib.py +0 -639
- pyxllib-0.0.43.dist-info/METADATA +0 -39
- pyxllib-0.0.43.dist-info/RECORD +0 -80
- pyxllib-0.0.43.dist-info/top_level.txt +0 -1
- {pyxllib-0.0.43.dist-info → pyxllib-0.3.197.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,2829 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
# -*- coding: utf-8 -*-
|
3
|
+
# @Author : 陈坤泽
|
4
|
+
# @Email : 877362867@qq.com
|
5
|
+
# @Date : 2020/05/30 20:37
|
6
|
+
|
7
|
+
from pyxllib.prog.pupil import check_install_package
|
8
|
+
|
9
|
+
check_install_package('filetype')
|
10
|
+
|
11
|
+
from typing import Callable, Any
|
12
|
+
import io
|
13
|
+
import json
|
14
|
+
import os
|
15
|
+
import pathlib
|
16
|
+
import pickle
|
17
|
+
import re
|
18
|
+
import shutil
|
19
|
+
import subprocess
|
20
|
+
import tempfile
|
21
|
+
import ujson
|
22
|
+
from collections import defaultdict, Counter
|
23
|
+
import math
|
24
|
+
from itertools import islice
|
25
|
+
import datetime
|
26
|
+
|
27
|
+
# import chardet
|
28
|
+
import charset_normalizer
|
29
|
+
import qiniu
|
30
|
+
import requests
|
31
|
+
import yaml
|
32
|
+
import humanfriendly
|
33
|
+
from more_itertools import chunked
|
34
|
+
import filetype
|
35
|
+
from tqdm import tqdm
|
36
|
+
|
37
|
+
from pyxllib.prog.newbie import round_int, human_readable_size
|
38
|
+
from pyxllib.prog.pupil import is_url, is_file, DictTool
|
39
|
+
from pyxllib.algo.pupil import Groups
|
40
|
+
from pyxllib.file.pupil import struct_unpack, gen_file_filter
|
41
|
+
|
42
|
+
|
43
|
+
def __1_judge():
|
44
|
+
pass
|
45
|
+
|
46
|
+
|
47
|
+
def is_url_connect(url, timeout=5):
|
48
|
+
try:
|
49
|
+
_ = requests.head(url, timeout=timeout)
|
50
|
+
return True
|
51
|
+
except requests.ConnectionError:
|
52
|
+
pass
|
53
|
+
return False
|
54
|
+
|
55
|
+
|
56
|
+
def __2_qiniu():
|
57
|
+
pass
|
58
|
+
|
59
|
+
|
60
|
+
class GetEtag:
|
61
|
+
""" 七牛原有etag功能基础上做封装 """
|
62
|
+
|
63
|
+
@classmethod
|
64
|
+
def from_bytes(cls, _bytes):
|
65
|
+
return qiniu.utils.etag_stream(io.BytesIO(_bytes))
|
66
|
+
|
67
|
+
@classmethod
|
68
|
+
def from_text(cls, text):
|
69
|
+
_bytes = text.encode('utf8')
|
70
|
+
return qiniu.utils.etag_stream(io.BytesIO(_bytes))
|
71
|
+
|
72
|
+
@classmethod
|
73
|
+
def from_file(cls, file):
|
74
|
+
return qiniu.etag(file)
|
75
|
+
|
76
|
+
@classmethod
|
77
|
+
def from_url(cls, url):
|
78
|
+
return cls(requests.get(url).content)
|
79
|
+
|
80
|
+
|
81
|
+
# @deprecated.deprecated
|
82
|
+
def get_etag(arg):
|
83
|
+
""" 七牛原有etag功能基础上做封装
|
84
|
+
|
85
|
+
:param arg: 支持bytes二进制、文件、url地址
|
86
|
+
|
87
|
+
只跟文件内容有关,跟文件创建、修改日期没关系
|
88
|
+
如果读取文件后再处理etag,要尤其小心 '\r\n' 的问题!
|
89
|
+
文件里如果是\r\n,我的File.read会变成\n,所以按文件取etag和read的内容算etag会不一样。
|
90
|
+
"""
|
91
|
+
if isinstance(arg, bytes): # 二进制数据
|
92
|
+
return qiniu.utils.etag_stream(io.BytesIO(arg))
|
93
|
+
elif is_file(arg): # 输入是一个文件
|
94
|
+
return qiniu.etag(arg)
|
95
|
+
elif is_url(arg): # 输入是一个网页上的数据源
|
96
|
+
return get_etag(requests.get(arg).content)
|
97
|
+
elif isinstance(arg, str): # 明文字符串转二进制
|
98
|
+
return get_etag(arg.encode('utf8'))
|
99
|
+
else:
|
100
|
+
raise TypeError('不识别的数据类型')
|
101
|
+
|
102
|
+
|
103
|
+
def is_etag(s):
|
104
|
+
""" 字母、数字和-、_共64种字符构成的长度28的字符串 """
|
105
|
+
return re.match(r'[a-zA-Z0-9\-_]{28}$', s)
|
106
|
+
|
107
|
+
|
108
|
+
def test_etag():
|
109
|
+
print(get_etag(r'\chematom{+8}{2}{8}{}'))
|
110
|
+
# Fjnu-ZXyDxrqLoZmNJ2Kj8FcZGR-
|
111
|
+
|
112
|
+
print(get_etag(__file__))
|
113
|
+
# 每次代码改了这段输出都是不一样的
|
114
|
+
|
115
|
+
|
116
|
+
def test_etag2():
|
117
|
+
""" 字符串值和写到文件判断的etag,是一样的
|
118
|
+
|
119
|
+
"""
|
120
|
+
s = 'code4101'
|
121
|
+
print(get_etag(s))
|
122
|
+
# FkAD2McB6ugxTiniE8ebhlNHdHh9
|
123
|
+
|
124
|
+
f = File('1.tex', tempfile.gettempdir()).write(s, if_exists='replace').to_str()
|
125
|
+
print(get_etag(f))
|
126
|
+
# FkAD2McB6ugxTiniE8ebhlNHdHh9
|
127
|
+
|
128
|
+
|
129
|
+
def __3_chardet():
|
130
|
+
pass
|
131
|
+
|
132
|
+
|
133
|
+
def get_encoding(data,
|
134
|
+
cp_isolation=('utf_8', 'gbk', 'gb18030', 'utf_16'),
|
135
|
+
preemptive_behaviour=True,
|
136
|
+
explain=False):
|
137
|
+
""" 从字节串中检测编码类型
|
138
|
+
|
139
|
+
:param bytes data: 要检测的字节串
|
140
|
+
:param List[str] cp_isolation: 指定要检测的字符集编码类型列表,只检测该列表中指定的编码类型,而不是所有可能的编码类型,默认为 None
|
141
|
+
注意charset_normalizer是无法识别utf-8-sig的情况的。但要获得解析后的文本内容,其实也不用通过get_encoding来中转。
|
142
|
+
:param bool preemptive_behaviour: 指定预处理行为,如果设置为 True,将在检测之前对数据进行预处理,例如去除 BOM、转换大小写等操作,默认为 True
|
143
|
+
:param bool explain: 指定是否打印出检测过程的详细信息,如果设置为 True,将打印出每个 chunk 的检测结果和置信度,默认为 False
|
144
|
+
:return str: 检测到的编码类型,返回字符串表示
|
145
|
+
"""
|
146
|
+
result = charset_normalizer.from_bytes(data,
|
147
|
+
cp_isolation=cp_isolation,
|
148
|
+
preemptive_behaviour=preemptive_behaviour,
|
149
|
+
explain=explain)
|
150
|
+
best_match = result.best()
|
151
|
+
if best_match:
|
152
|
+
return best_match.encoding
|
153
|
+
else: # 注意,这个实现是有可能会找不到编码的,此时默认返回None
|
154
|
+
return
|
155
|
+
|
156
|
+
|
157
|
+
def __4_file():
|
158
|
+
"""
|
159
|
+
路径、文件、目录相关操作功能
|
160
|
+
|
161
|
+
主要是为了提供readfile、wrritefile函数
|
162
|
+
与普通的读写文件相比,有以下优点:
|
163
|
+
1、智能识别pkl等特殊格式文件的处理
|
164
|
+
2、智能处理编码
|
165
|
+
3、目录不存在自动创建
|
166
|
+
4、自动备份旧文件,而不是强制覆盖写入
|
167
|
+
|
168
|
+
其他相关文件处理组件:isfile、get_encoding、ensure_folders
|
169
|
+
"""
|
170
|
+
|
171
|
+
|
172
|
+
class PathBase:
|
173
|
+
""" File和Dir共有的操作逻辑功能 """
|
174
|
+
__slots__ = ('_path',)
|
175
|
+
|
176
|
+
@classmethod
|
177
|
+
def abspath(cls, path=None, root=None, *, suffix=None, resolve=True) -> pathlib.Path:
|
178
|
+
r""" 根据各种不同的组合参数信息,推导出具体的路径位置
|
179
|
+
|
180
|
+
:param path: 主要的参数,如果后面的参数有矛盾,以path为最高参考标准
|
181
|
+
...,可以输入Ellipsis对象,效果跟None是不一样的,意思是显式地指明要生成一个随机名称的文件
|
182
|
+
三个参数全空时,则返回当前目录
|
183
|
+
:param suffix:
|
184
|
+
以 '.' 指明的扩展名,会强制替换
|
185
|
+
否则,只作为参考扩展名,只在原有path没有指明的时候才添加
|
186
|
+
:param root: 未输入的时候,则为当前工作目录
|
187
|
+
|
188
|
+
>>> os.chdir("C:/Users")
|
189
|
+
|
190
|
+
# 未输入任何参数,则返回当前工作目录
|
191
|
+
>>> PathBase.abspath()
|
192
|
+
WindowsPath('C:/Users')
|
193
|
+
|
194
|
+
# 基本的指定功能
|
195
|
+
>>> PathBase.abspath('a')
|
196
|
+
WindowsPath('C:/Users/a')
|
197
|
+
|
198
|
+
# 额外指定父目录
|
199
|
+
>>> PathBase.abspath('a/b', 'D:')
|
200
|
+
WindowsPath('D:/a/b')
|
201
|
+
|
202
|
+
# 设置扩展名
|
203
|
+
>>> PathBase.abspath('a', suffix='.txt')
|
204
|
+
WindowsPath('C:/Users/a.txt')
|
205
|
+
|
206
|
+
# 扩展名的高级用法
|
207
|
+
>>> PathBase.abspath('F:/work/a.txt', suffix='py') # 参考后缀不修改
|
208
|
+
WindowsPath('F:/work/a.txt')
|
209
|
+
>>> PathBase.abspath('F:/work/a.txt', suffix='.py') # 强制后缀会修改
|
210
|
+
WindowsPath('F:/work/a.py')
|
211
|
+
>>> PathBase.abspath('F:/work/a.txt', suffix='') # 删除后缀
|
212
|
+
WindowsPath('F:/work/a')
|
213
|
+
|
214
|
+
# 在临时目录下,新建一个.tex的随机名称文件
|
215
|
+
>> PathBase.abspath(..., tempfile.gettempdir(), suffix='.tex')
|
216
|
+
WindowsPath('D:/Temp/tmp_sey0yeg.tex')
|
217
|
+
"""
|
218
|
+
# 1 判断参考目录
|
219
|
+
if root is None:
|
220
|
+
root = os.getcwd()
|
221
|
+
else:
|
222
|
+
root = str(root)
|
223
|
+
|
224
|
+
# 2 判断主体文件名 path
|
225
|
+
if path is None:
|
226
|
+
return pathlib.Path(root)
|
227
|
+
elif path is Ellipsis:
|
228
|
+
path = tempfile.mktemp(dir=root)
|
229
|
+
else:
|
230
|
+
path = os.path.join(root, str(path))
|
231
|
+
|
232
|
+
# 3 补充suffix
|
233
|
+
if suffix is not None:
|
234
|
+
# 判断后缀
|
235
|
+
li = os.path.splitext(path)
|
236
|
+
if suffix == '' or suffix[0] == '.':
|
237
|
+
path = li[0] + suffix
|
238
|
+
elif li[1] == '':
|
239
|
+
path = li[0] + '.' + suffix
|
240
|
+
|
241
|
+
if resolve:
|
242
|
+
return pathlib.Path(path).resolve()
|
243
|
+
else:
|
244
|
+
return pathlib.Path(path)
|
245
|
+
|
246
|
+
def __bool__(self):
|
247
|
+
r""" 判断文件、文件夹是否存在
|
248
|
+
|
249
|
+
重置WindowsPath的bool逻辑,返回值变成存在True,不存在为False
|
250
|
+
|
251
|
+
>>> bool(File('C:/Windows/System32/cmd.exe'))
|
252
|
+
True
|
253
|
+
>>> bool(File('C:/Windows/System32/cmdcmd.exe'))
|
254
|
+
False
|
255
|
+
"""
|
256
|
+
return self._path.exists()
|
257
|
+
|
258
|
+
def __repr__(self):
|
259
|
+
s = self._path.__repr__()
|
260
|
+
if s.startswith('WindowsPath'):
|
261
|
+
s = self.__class__.__name__ + s[11:]
|
262
|
+
elif s.startswith('PosixPath'):
|
263
|
+
s = self.__class__.__name__ + s[9:]
|
264
|
+
return s
|
265
|
+
|
266
|
+
def __str__(self):
|
267
|
+
return str(self._path).replace('\\', '/')
|
268
|
+
|
269
|
+
def to_str(self):
|
270
|
+
return self.__str__()
|
271
|
+
|
272
|
+
def resolve(self):
|
273
|
+
return self._path.resolve()
|
274
|
+
|
275
|
+
@property
|
276
|
+
def drive(self) -> str:
|
277
|
+
return self._path.drive
|
278
|
+
|
279
|
+
@drive.setter
|
280
|
+
def drive(self, value):
|
281
|
+
"""修改磁盘位置"""
|
282
|
+
raise NotImplementedError
|
283
|
+
|
284
|
+
@property
|
285
|
+
def name(self) -> str:
|
286
|
+
r"""
|
287
|
+
>>> File('D:/pycode/a.txt').name
|
288
|
+
'a.txt'
|
289
|
+
>>> File('D:/pycode/code4101py').name
|
290
|
+
'code4101py'
|
291
|
+
>>> File('D:/pycode/.gitignore').name
|
292
|
+
'.gitignore'
|
293
|
+
"""
|
294
|
+
return self._path.name
|
295
|
+
|
296
|
+
@name.setter
|
297
|
+
def name(self, value):
|
298
|
+
raise NotImplementedError
|
299
|
+
|
300
|
+
@property
|
301
|
+
def parent(self):
|
302
|
+
r"""
|
303
|
+
>>> File('D:/pycode/code4101py').parent
|
304
|
+
WindowsPath('D:/pycode')
|
305
|
+
"""
|
306
|
+
return self._path.parent
|
307
|
+
|
308
|
+
@property
|
309
|
+
def dirname(self) -> str:
|
310
|
+
r"""
|
311
|
+
>>> File('D:/pycode/code4101py').dirname
|
312
|
+
'D:\\pycode'
|
313
|
+
>>> File(r'D:\toweb\a').dirname
|
314
|
+
'D:\\toweb'
|
315
|
+
"""
|
316
|
+
return str(self.parent)
|
317
|
+
|
318
|
+
@property
|
319
|
+
def parts(self) -> tuple:
|
320
|
+
r"""
|
321
|
+
>>> File('D:/pycode/code4101py').parts
|
322
|
+
('D:\\', 'pycode', 'code4101py')
|
323
|
+
"""
|
324
|
+
return self._path.parts
|
325
|
+
|
326
|
+
@property
|
327
|
+
def backup_time(self):
|
328
|
+
r""" 返回文件的备份时间戳,如果并不是备份文件,则返回空字符串
|
329
|
+
|
330
|
+
备份文件都遵循特定的命名规范
|
331
|
+
如果是文件,是:'chePre 171020-153959.tex'
|
332
|
+
如果是目录,是:'figs 171020-153959'
|
333
|
+
通过后缀分析,可以判断这是不是一个备份文件
|
334
|
+
|
335
|
+
>>> File('chePre 171020-153959.tex').backup_time
|
336
|
+
'171020-153959'
|
337
|
+
>>> File('figs 171020-153959').backup_time
|
338
|
+
'171020-153959'
|
339
|
+
>>> File('figs 171020').backup_time
|
340
|
+
''
|
341
|
+
"""
|
342
|
+
name = self.stem
|
343
|
+
if len(name) < 14:
|
344
|
+
return ''
|
345
|
+
g = re.match(r'(\d{6}-\d{6})', name[-13:])
|
346
|
+
return g.group(1) if g else ''
|
347
|
+
|
348
|
+
@property
|
349
|
+
def mtime(self):
|
350
|
+
r""" 文件的最近修改时间
|
351
|
+
|
352
|
+
>> PathBase(r"C:\pycode\code4101py").mtime
|
353
|
+
datetime.datetime(2021, 9, 6, 17, 28, 18, 960713)
|
354
|
+
"""
|
355
|
+
from datetime import datetime
|
356
|
+
return datetime.fromtimestamp(os.stat(str(self)).st_mtime)
|
357
|
+
|
358
|
+
@property
|
359
|
+
def ctime(self):
|
360
|
+
r""" 文件的创建时间
|
361
|
+
|
362
|
+
>> Path(r"C:\pycode\code4101py").ctime
|
363
|
+
2018-05-25 10:46:37
|
364
|
+
"""
|
365
|
+
from datetime import datetime
|
366
|
+
# 注意:st_ctime是平台相关的值,在windows是创建时间,但在Unix是metadate最近修改时间
|
367
|
+
return datetime.fromtimestamp(os.stat(str(self)).st_ctime)
|
368
|
+
|
369
|
+
def relpath(self, ref_dir) -> str:
|
370
|
+
r""" 当前路径,相对于ref_dir的路径位置
|
371
|
+
|
372
|
+
>>> File('C:/a/b/c.txt').relpath('C:/a/')
|
373
|
+
'b/c.txt'
|
374
|
+
>>> File('C:/a/b\\c.txt').relpath('C:\\a/')
|
375
|
+
'b/c.txt'
|
376
|
+
|
377
|
+
>> File('C:/a/b/c.txt').relpath('D:/') # ValueError
|
378
|
+
"""
|
379
|
+
return os.path.relpath(str(self), str(ref_dir)).replace('\\', '/')
|
380
|
+
|
381
|
+
def exist_preprcs(self, if_exists=None):
|
382
|
+
""" 这个实际上是在做copy等操作前,如果目标文件已存在,需要预先删除等的预处理
|
383
|
+
并返回判断,是否需要执行下一步操作
|
384
|
+
|
385
|
+
有时候情况比较复杂,process无法满足需求时,可以用exist_preprcs这个底层函数协助
|
386
|
+
|
387
|
+
:param if_exists:
|
388
|
+
None: 不做任何处理,直接运行,依赖于功能本身是否有覆盖写入机制
|
389
|
+
'error': 如果要替换的目标文件已经存在,则报错
|
390
|
+
'replace': 把存在的文件先删除
|
391
|
+
本来是叫'delete'更准确的,但是考虑用户理解,
|
392
|
+
一般都是用在文件替换场合,叫成'delete'会非常怪异,带来不必要的困扰、误解
|
393
|
+
所以还是决定叫'replace'
|
394
|
+
'skip': 不执行后续功能
|
395
|
+
'backup': 先做备份 (对原文件先做一个备份)
|
396
|
+
"""
|
397
|
+
need_run = True
|
398
|
+
if self.exists():
|
399
|
+
if if_exists is None:
|
400
|
+
return need_run
|
401
|
+
elif if_exists == 'error':
|
402
|
+
raise FileExistsError(f'目标文件已存在: {self}')
|
403
|
+
elif if_exists == 'replace':
|
404
|
+
self.delete()
|
405
|
+
elif if_exists == 'skip':
|
406
|
+
need_run = False
|
407
|
+
elif if_exists == 'backup':
|
408
|
+
self.backup(move=True)
|
409
|
+
else:
|
410
|
+
raise ValueError(f'{if_exists}')
|
411
|
+
return need_run
|
412
|
+
|
413
|
+
def copy(self, *args, **kwargs):
|
414
|
+
raise NotImplementedError
|
415
|
+
|
416
|
+
def delete(self):
|
417
|
+
raise NotImplementedError
|
418
|
+
|
419
|
+
def absdst(self, dst):
|
420
|
+
raise NotImplementedError
|
421
|
+
|
422
|
+
def backup(self, tail=None, if_exists='replace', move=False):
|
423
|
+
r""" 对文件末尾添加时间戳备份,也可以使用自定义标记tail
|
424
|
+
|
425
|
+
:param tail: 自定义添加后缀
|
426
|
+
tail为None时,默认添加特定格式的时间戳
|
427
|
+
:param if_exists: 备份的目标文件名存在时的处理方案
|
428
|
+
这个概率非常小,真遇到,先把已存在的删掉,重新写入一个是可以接受的
|
429
|
+
:param move: 是否删除原始文件
|
430
|
+
|
431
|
+
# TODO:有个小bug,如果在不同时间实际都是相同一个文件,也会被不断反复备份
|
432
|
+
# 如果想解决这个,就要读取目录下最近的备份文件对比内容了
|
433
|
+
"""
|
434
|
+
# 1 判断自身文件是否存在
|
435
|
+
if not self:
|
436
|
+
return None
|
437
|
+
|
438
|
+
# 2 计算出新名称
|
439
|
+
if not tail:
|
440
|
+
tail = self.mtime.strftime(' %y%m%d-%H%M%S') # 时间戳
|
441
|
+
name, ext = os.path.splitext(str(self))
|
442
|
+
dst = name + tail + ext
|
443
|
+
|
444
|
+
# 3 备份就是特殊的copy操作
|
445
|
+
if move:
|
446
|
+
return self.move(dst, if_exists)
|
447
|
+
else:
|
448
|
+
return self.copy(dst, if_exists)
|
449
|
+
|
450
|
+
def move(self, dst, if_exists=None):
|
451
|
+
""" 移动文件
|
452
|
+
"""
|
453
|
+
if self:
|
454
|
+
return self.process(dst, shutil.move, if_exists)
|
455
|
+
|
456
|
+
def process(self, dst, func, if_exists=None):
|
457
|
+
r""" copy或move的本质底层实现
|
458
|
+
|
459
|
+
:param dst: 目标路径对象
|
460
|
+
:param func: 传入arg1和arg2参数,可以自定义
|
461
|
+
默认分别是self和dst的字符串
|
462
|
+
:return : 返回dst
|
463
|
+
"""
|
464
|
+
# 1 判断目标是否已存在,进行不同的指定规则处理
|
465
|
+
dst_ = self.absdst(dst)
|
466
|
+
|
467
|
+
# 2 执行特定功能
|
468
|
+
if self == dst_:
|
469
|
+
# 如果文件是自身的话,并不算exists,可以直接run,不用exist_preprcs
|
470
|
+
dst_.ensure_parent()
|
471
|
+
func(str(self), str(dst_))
|
472
|
+
dst_._path = dst_._path.resolve() # 但是需要重新解析一次dst_._path,避免可能有重命名等大小写变化
|
473
|
+
elif dst_.exist_preprcs(if_exists):
|
474
|
+
dst_.ensure_parent()
|
475
|
+
func(str(self), str(dst_))
|
476
|
+
|
477
|
+
return dst_
|
478
|
+
|
479
|
+
def explorer(self, proc='explorer'):
|
480
|
+
""" 使用windows的explorer命令打开文件
|
481
|
+
|
482
|
+
还有个类似的万能打开命令 start
|
483
|
+
|
484
|
+
:param proc: 可以自定义要执行的主程序
|
485
|
+
"""
|
486
|
+
subprocess.run([proc, str(self)])
|
487
|
+
|
488
|
+
def ensure_parent(self):
|
489
|
+
r""" 确保父目录存在
|
490
|
+
"""
|
491
|
+
p = self.parent
|
492
|
+
if not p.exists():
|
493
|
+
os.makedirs(str(p))
|
494
|
+
|
495
|
+
def __getattr__(self, item):
|
496
|
+
return getattr(self._path, item)
|
497
|
+
|
498
|
+
def start(self):
|
499
|
+
os.startfile(str(self))
|
500
|
+
|
501
|
+
|
502
|
+
class File(PathBase):
|
503
|
+
r""" 通用文件处理类,大部分基础功能是从pathlib.Path衍生过来的
|
504
|
+
|
505
|
+
document: https://www.yuque.com/xlpr/python/pyxllib.debug.path
|
506
|
+
"""
|
507
|
+
__slots__ = ('_path',)
|
508
|
+
|
509
|
+
# 一、基础功能
|
510
|
+
|
511
|
+
def __init__(self, path, root=None, *, suffix=None, check=True):
|
512
|
+
r""" 初始化参数含义详见 PathBase.abspath 函数解释
|
513
|
+
|
514
|
+
:param path: 只传入一个File、pathlib.Path对象,可以提高初始化速度,不会进行多余的解析判断
|
515
|
+
如果要安全保守一点,可以传入str类型的path
|
516
|
+
:param root: 父目录
|
517
|
+
:param check: 在某些特殊场合、内部开发中,可以确保传参一定不会出错,在上游已经有了严谨的检查
|
518
|
+
此时可以设置check=False,提高初始化速度
|
519
|
+
|
520
|
+
注意!如果使用了符号链接(软链接),则路径是会解析转向实际位置的!例如
|
521
|
+
>> File('D:/pycode/code4101py')
|
522
|
+
File('D:/slns/pycode/code4101py')
|
523
|
+
"""
|
524
|
+
self._path = None
|
525
|
+
|
526
|
+
# 1 快速初始化
|
527
|
+
if root is None and suffix is None:
|
528
|
+
if isinstance(path, File):
|
529
|
+
self._path = path._path
|
530
|
+
return # 直接完成初始化过程
|
531
|
+
elif isinstance(path, pathlib.Path):
|
532
|
+
self._path = path
|
533
|
+
|
534
|
+
# 2 普通初始化
|
535
|
+
if self._path is None:
|
536
|
+
self._path = self.abspath(path, root, suffix=suffix)
|
537
|
+
|
538
|
+
# 3 检查
|
539
|
+
if check:
|
540
|
+
if not self._path:
|
541
|
+
raise ValueError(f'无效路径 {self._path}')
|
542
|
+
elif self._path.is_dir():
|
543
|
+
raise ValueError(f'不能用目录初始化一个File对象 {self._path}')
|
544
|
+
|
545
|
+
@classmethod
|
546
|
+
def safe_init(cls, path, root=None, *, suffix=None):
|
547
|
+
""" 如果失败不raise,而是返回None的初始化方式 """
|
548
|
+
try:
|
549
|
+
f = File(path, root, suffix=suffix)
|
550
|
+
f._path.is_file() # 有些问题上一步不一定测的出来,要再补一个测试
|
551
|
+
return f
|
552
|
+
except (ValueError, TypeError, OSError, PermissionError):
|
553
|
+
# ValueError:文件名过长,代表输入很可能是一段文本,根本不是路径
|
554
|
+
# TypeError:不是str等正常的参数
|
555
|
+
# OSError:非法路径名,例如有 *? 等
|
556
|
+
# PermissionError: linux上访问无权限、不存在的路径
|
557
|
+
return None
|
558
|
+
|
559
|
+
# 二、获取、修改路径中部分值的功能
|
560
|
+
|
561
|
+
def with_dirname(self, value):
|
562
|
+
return File(self.name, value)
|
563
|
+
|
564
|
+
@property
|
565
|
+
def stem(self) -> str:
|
566
|
+
r"""
|
567
|
+
>>> File('D:/pycode/code4101py/ckz.py').stem
|
568
|
+
'ckz'
|
569
|
+
>>> File('D:/pycode/.gitignore').stem # os.path.splitext也是这种算法
|
570
|
+
'.gitignore'
|
571
|
+
>>> File('D:/pycode/.123.45.6').stem
|
572
|
+
'.123.45'
|
573
|
+
"""
|
574
|
+
return self._path.stem
|
575
|
+
|
576
|
+
def with_stem(self, stem):
|
577
|
+
"""
|
578
|
+
注意不能用@stem.setter来做
|
579
|
+
如果setter要rename,那if_exists参数怎么控制?
|
580
|
+
如果setter不要rename,那和用with_stem实现有什么区别?
|
581
|
+
"""
|
582
|
+
return File(stem, self.parent, suffix=self.suffix)
|
583
|
+
|
584
|
+
def with_name(self, name):
|
585
|
+
return File(name, self.parent)
|
586
|
+
|
587
|
+
@property
|
588
|
+
def suffix(self) -> str:
|
589
|
+
r"""
|
590
|
+
>>> File('D:/pycode/code4101py/ckz.py').suffix
|
591
|
+
'.py'
|
592
|
+
>>> File('D:/pycode/code4101py').suffix
|
593
|
+
''
|
594
|
+
>>> File('D:/pycode/code4101py/ckz.').suffix
|
595
|
+
''
|
596
|
+
>>> File('D:/pycode/code4101py/ckz.123.456').suffix
|
597
|
+
'.456'
|
598
|
+
>>> File('D:/pycode/code4101py/ckz.123..456').suffix
|
599
|
+
'.456'
|
600
|
+
>>> File('D:/pycode/.gitignore').suffix
|
601
|
+
''
|
602
|
+
"""
|
603
|
+
return self._path.suffix
|
604
|
+
|
605
|
+
def with_suffix(self, suffix):
|
606
|
+
r""" 指向同目录下后缀为suffix的文件
|
607
|
+
|
608
|
+
>>> File('a.txt').with_suffix('.py').name # 强制替换
|
609
|
+
'a.py'
|
610
|
+
>>> File('a.txt').with_suffix('py').name # 参考替换
|
611
|
+
'a.txt'
|
612
|
+
>>> File('a.txt').with_suffix('').name # 删除
|
613
|
+
'a'
|
614
|
+
"""
|
615
|
+
return File(self.abspath(self._path, suffix=suffix))
|
616
|
+
|
617
|
+
# 三、获取文件相关属性值功能
|
618
|
+
|
619
|
+
# @property
|
620
|
+
# def encoding(self):
|
621
|
+
# """ 文件的编码
|
622
|
+
#
|
623
|
+
# 非文件、不存在时返回 None
|
624
|
+
# """
|
625
|
+
# if self:
|
626
|
+
# return get_encoding(str(self))
|
627
|
+
|
628
|
+
@property
|
629
|
+
def size(self) -> int:
|
630
|
+
""" 计算文件大小
|
631
|
+
"""
|
632
|
+
if self.exists():
|
633
|
+
total_size = os.path.getsize(str(self))
|
634
|
+
else:
|
635
|
+
total_size = 0
|
636
|
+
return total_size
|
637
|
+
|
638
|
+
def size2(self) -> int:
|
639
|
+
""" 220102周日17:23
|
640
|
+
|
641
|
+
size有点bug,临时写个函数接口
|
642
|
+
这个bug有点莫名其妙,搞不定
|
643
|
+
"""
|
644
|
+
total_size = os.path.getsize(str(self))
|
645
|
+
return total_size
|
646
|
+
|
647
|
+
# 四、文件操作功能
|
648
|
+
|
649
|
+
def absdst(self, dst):
|
650
|
+
""" 在copy、move等中,给了个"模糊"的目标位置dst,智能推导出实际file、dir绝对路径
|
651
|
+
"""
|
652
|
+
from pyxllib.file.specialist.dirlib import Dir
|
653
|
+
dst_ = self.abspath(dst)
|
654
|
+
if isinstance(dst, Dir) or (isinstance(dst, str) and dst[-1] in ('\\', '/')) or dst_.is_dir():
|
655
|
+
dst_ = File(self.name, dst_)
|
656
|
+
else:
|
657
|
+
dst_ = File(dst_)
|
658
|
+
return dst_
|
659
|
+
|
660
|
+
def copy(self, dst, if_exists=None):
|
661
|
+
""" 复制文件
|
662
|
+
"""
|
663
|
+
return self.process(dst, shutil.copy2, if_exists)
|
664
|
+
|
665
|
+
def rename(self, dst, if_exists=None):
|
666
|
+
r""" 文件重命名,或者也可以理解成文件移动
|
667
|
+
该接口和move的核心区别:move的dst是相对工作目录,而rename则是相对self.parent路径
|
668
|
+
"""
|
669
|
+
# rename是move的一种特殊情况
|
670
|
+
return self.move(File(dst, self.parent), if_exists)
|
671
|
+
|
672
|
+
def delete(self):
|
673
|
+
r""" 删除自身文件
|
674
|
+
"""
|
675
|
+
if self.is_file():
|
676
|
+
os.remove(str(self))
|
677
|
+
|
678
|
+
# 五、其他综合性功能
|
679
|
+
|
680
|
+
def read(self, *, encoding=None, mode=None):
|
681
|
+
""" 读取文件
|
682
|
+
|
683
|
+
:param encoding: 文件编码
|
684
|
+
默认None,则在需要使用encoding参数的场合,会使用self.encoding自动判断编码
|
685
|
+
:param mode: 读取模式(例如 '.json'),默认从扩展名识别,也可以强制指定
|
686
|
+
'b': 特殊标记,表示按二进制读取文件内容
|
687
|
+
:return:
|
688
|
+
"""
|
689
|
+
if self: # 如果存在这样的文件,那就读取文件内容
|
690
|
+
# 获得文件扩展名,并统一转成小写
|
691
|
+
name, suffix = str(self), self.suffix
|
692
|
+
if not mode: mode = suffix
|
693
|
+
mode = mode.lower()
|
694
|
+
if mode == 'bytes':
|
695
|
+
with open(name, 'rb') as f:
|
696
|
+
return f.read()
|
697
|
+
elif mode == '.pkl': # pickle库
|
698
|
+
with open(name, 'rb') as f:
|
699
|
+
return pickle.load(f)
|
700
|
+
elif mode == '.json':
|
701
|
+
# 先读成字符串,再解析,会比rb鲁棒性更强,能自动过滤掉开头可能非正文特殊标记的字节
|
702
|
+
with open(name, 'rb') as f:
|
703
|
+
bstr = f.read()
|
704
|
+
if not encoding: encoding = get_encoding(bstr)
|
705
|
+
try:
|
706
|
+
return ujson.loads(bstr.decode(encoding=encoding))
|
707
|
+
except ValueError: # ujson会有些不太标准的情况处理不了
|
708
|
+
return json.loads(bstr.decode(encoding=encoding))
|
709
|
+
elif mode == '.yaml':
|
710
|
+
with open(name, 'r', encoding=encoding) as f:
|
711
|
+
return yaml.safe_load(f.read())
|
712
|
+
elif mode in ('.jpg', '.jpeg', '.png', '.bmp', 'b'):
|
713
|
+
# 二进制读取
|
714
|
+
with open(name, 'rb') as fp:
|
715
|
+
return fp.read()
|
716
|
+
else:
|
717
|
+
with open(name, 'rb') as f:
|
718
|
+
bstr = f.read()
|
719
|
+
if not encoding:
|
720
|
+
encoding = get_encoding(bstr)
|
721
|
+
if not encoding:
|
722
|
+
raise ValueError(f'{self} 自动识别编码失败,请手动指定文件编码')
|
723
|
+
s = bstr.decode(encoding=encoding, errors='ignore')
|
724
|
+
if '\r' in s: s = s.replace('\r\n', '\n') # 如果用\r\n作为换行符会有一些意外不好处理
|
725
|
+
return s
|
726
|
+
else: # 非文件对象
|
727
|
+
raise FileNotFoundError(f'{self} 文件不存在,无法读取。')
|
728
|
+
|
729
|
+
def write(self, ob, *, encoding='utf8', if_exists=None, mode=None, **kwargs):
|
730
|
+
""" 保存为文件
|
731
|
+
|
732
|
+
:param ob: 写入的内容
|
733
|
+
如果要写txt文本文件且ob不是文本对象,只会进行简单的字符串化
|
734
|
+
:param encoding: 强制写入的编码
|
735
|
+
如果原文件存在且有编码,则使用原文件的编码
|
736
|
+
如果没有,则默认使用utf8
|
737
|
+
当然,其实有些格式是用不到编码信息的~~例如pkl文件
|
738
|
+
:param if_exists: 如果文件已存在,要进行的操作
|
739
|
+
:param mode: 写入模式(例如 '.json'),默认从扩展名识别,也可以强制指定
|
740
|
+
:param kwargs:
|
741
|
+
写入json格式的时候
|
742
|
+
ensure_ascii: json.dump默认是True,但是我这里默认值改成了False
|
743
|
+
改成False可以支持在json直接显示中文明文
|
744
|
+
indent: json.dump是None,我这里默认值遵循json.dump
|
745
|
+
我原来是2,让文件结构更清晰、更加易读
|
746
|
+
:return: 返回写入的文件名,这个主要是在写临时文件时有用
|
747
|
+
"""
|
748
|
+
|
749
|
+
# # 将ob写入文件path
|
750
|
+
# def get_enc():
|
751
|
+
# # 编码在需要的时候才获取分析,减少不必要的运算开销
|
752
|
+
# # 所以封装一个函数接口,需要的时候再计算
|
753
|
+
# if encoding is None:
|
754
|
+
# # return self.encoding or 'utf8'
|
755
|
+
# return encoding
|
756
|
+
|
757
|
+
if self.exist_preprcs(if_exists):
|
758
|
+
self.ensure_parent()
|
759
|
+
name, suffix = str(self), self.suffix
|
760
|
+
if not mode: mode = suffix
|
761
|
+
mode = mode.lower()
|
762
|
+
if mode == '.pkl':
|
763
|
+
with open(name, 'wb') as f:
|
764
|
+
pickle.dump(ob, f)
|
765
|
+
elif mode == '.json':
|
766
|
+
with open(name, 'w', encoding=encoding) as f:
|
767
|
+
DictTool.ior(kwargs, {'ensure_ascii': False})
|
768
|
+
json.dump(ob, f, **kwargs)
|
769
|
+
elif mode == '.yaml':
|
770
|
+
with open(name, 'w', encoding=encoding) as f:
|
771
|
+
yaml.dump(ob, f)
|
772
|
+
elif isinstance(ob, bytes):
|
773
|
+
with open(name, 'wb') as f:
|
774
|
+
f.write(ob)
|
775
|
+
else: # 其他类型认为是文本类型
|
776
|
+
with open(name, 'w', errors='ignore', encoding=encoding) as f:
|
777
|
+
f.write(str(ob))
|
778
|
+
|
779
|
+
return self
|
780
|
+
|
781
|
+
def unpack(self, dst_dir=None):
|
782
|
+
""" 解压缩
|
783
|
+
|
784
|
+
:param dst_dir: 如果没有输入,默认会套一层压缩文件原名的目录里
|
785
|
+
|
786
|
+
TODO 本来是想,如果没有传dst_dir,则类似Bandizip的自动解压机制,解压到当前文件夹
|
787
|
+
但是不太好实现
|
788
|
+
"""
|
789
|
+
p = str(self)
|
790
|
+
if not dst_dir:
|
791
|
+
dst_dir = os.path.splitext(p)[0]
|
792
|
+
shutil.unpack_archive(p, str(dst_dir))
|
793
|
+
|
794
|
+
def __eq__(self, other):
|
795
|
+
return str(self) == str(other)
|
796
|
+
|
797
|
+
def exists(self):
|
798
|
+
return self._path.exists()
|
799
|
+
|
800
|
+
|
801
|
+
def make_filter():
|
802
|
+
""" 从filesmatch """
|
803
|
+
|
804
|
+
|
805
|
+
class XlPath(type(pathlib.Path())):
|
806
|
+
|
807
|
+
@classmethod
|
808
|
+
def desktop(cls):
|
809
|
+
if os.getenv('Desktop', None): # 如果修改了win10默认的桌面路径,需要在环境变量添加一个正确的Desktop路径值
|
810
|
+
desktop = os.environ['Desktop']
|
811
|
+
else:
|
812
|
+
desktop = os.path.join(pathlib.Path.home(), 'Desktop') # 这个不一定准,桌面是有可能被移到D盘等的
|
813
|
+
return cls(desktop)
|
814
|
+
|
815
|
+
@classmethod
|
816
|
+
def userdir(cls):
|
817
|
+
from os.path import expanduser
|
818
|
+
return cls(expanduser("~"))
|
819
|
+
|
820
|
+
@classmethod
|
821
|
+
def tempdir(cls):
|
822
|
+
return cls(tempfile.gettempdir())
|
823
|
+
|
824
|
+
@classmethod
|
825
|
+
def create_tempdir_path(cls, dir=None):
|
826
|
+
if dir is None:
|
827
|
+
dir = tempfile.gettempdir()
|
828
|
+
dst = cls(tempfile.mktemp(dir=dir))
|
829
|
+
return dst
|
830
|
+
|
831
|
+
@classmethod
|
832
|
+
def create_tempfile_path(cls, suffix="", dir=None):
|
833
|
+
if dir is None:
|
834
|
+
dir = tempfile.gettempdir()
|
835
|
+
return cls(tempfile.mktemp(suffix=suffix, dir=dir))
|
836
|
+
|
837
|
+
tempfile = create_tempfile_path
|
838
|
+
|
839
|
+
@classmethod
|
840
|
+
def init(cls, path, root=None, *, suffix=None):
|
841
|
+
""" 仿照原来File的初始化接口形式 """
|
842
|
+
p = XlPath(path)
|
843
|
+
if root:
|
844
|
+
p = XlPath(root) / p
|
845
|
+
|
846
|
+
if suffix:
|
847
|
+
if suffix[0] == '.': # 使用.时强制修改后缀
|
848
|
+
p = p.with_suffix(suffix)
|
849
|
+
elif not p.suffix:
|
850
|
+
p = p.with_suffix('.' + suffix)
|
851
|
+
|
852
|
+
return p
|
853
|
+
|
854
|
+
@classmethod
|
855
|
+
def safe_init(cls, arg_in):
|
856
|
+
""" 输入任意类型的_in,会用比较安全的机制,判断其是否为一个有效的路径格式并初始化
|
857
|
+
初始化失败则返回None
|
858
|
+
"""
|
859
|
+
try:
|
860
|
+
p = XlPath(str(arg_in))
|
861
|
+
p.is_file() # 有些问题上一步不一定测的出来,要再补一个测试。具体存不存在是不是文件并不重要,而是使用这个能检查出问题。
|
862
|
+
return p
|
863
|
+
except (ValueError, TypeError, OSError, PermissionError):
|
864
|
+
# ValueError:文件名过长,代表输入很可能是一段文本,根本不是路径
|
865
|
+
# TypeError:不是str等正常的参数
|
866
|
+
# OSError:非法路径名,例如有 *? 等
|
867
|
+
# PermissionError: linux上访问无权限、不存在的路径
|
868
|
+
return None
|
869
|
+
|
870
|
+
def start(self, *args, **kwargs):
|
871
|
+
""" 使用关联的程序打开p,类似于双击的效果
|
872
|
+
|
873
|
+
这就像在 Windows 资源管理器中双击文件、文件夹
|
874
|
+
"""
|
875
|
+
os.startfile(self, *args, **kwargs)
|
876
|
+
|
877
|
+
def exists_type(self):
|
878
|
+
"""
|
879
|
+
不存在返回0,文件返回1,目录返回-1
|
880
|
+
这样任意两个文件类型编号相乘,负数就代表不匹配
|
881
|
+
"""
|
882
|
+
if self.is_file():
|
883
|
+
return 1
|
884
|
+
elif self.is_dir():
|
885
|
+
return -1
|
886
|
+
else:
|
887
|
+
return 0
|
888
|
+
|
889
|
+
def mtime(self):
|
890
|
+
""" 文件的修改时间 """
|
891
|
+
# windows会带小数,linux使用%Ts只有整数部分。
|
892
|
+
# 这里不用四舍五入,取整数部分就是对应的。
|
893
|
+
return int(os.stat(self).st_mtime)
|
894
|
+
|
895
|
+
def size(self, *, human_readable=False):
|
896
|
+
""" 获取文件/目录的大小 """
|
897
|
+
if self.is_file():
|
898
|
+
sz = os.path.getsize(self)
|
899
|
+
elif self.is_dir():
|
900
|
+
sz = sum([os.path.getsize(p) for p in self.rglob('*') if p.is_file()])
|
901
|
+
else:
|
902
|
+
sz = 0
|
903
|
+
|
904
|
+
if human_readable:
|
905
|
+
return humanfriendly.format_size(sz, binary=True)
|
906
|
+
else:
|
907
|
+
return sz
|
908
|
+
|
909
|
+
def sub_rel_paths(self, mtime=False):
|
910
|
+
""" 返回self目录下所有含递归的文件、目录,存储相对路径,as_posix
|
911
|
+
当带有mtime参数时,会返回字典,并附带返回mtime的值
|
912
|
+
|
913
|
+
主要用于 scp 同步目录数据时,对比目录下文件情况
|
914
|
+
"""
|
915
|
+
|
916
|
+
if mtime:
|
917
|
+
res = {}
|
918
|
+
for p in self.glob('**/*'):
|
919
|
+
res[p.relative_to(self).as_posix()] = p.mtime()
|
920
|
+
else:
|
921
|
+
res = set()
|
922
|
+
for p in self.glob('**/*'):
|
923
|
+
res.add(p.relative_to(self).as_posix())
|
924
|
+
return res
|
925
|
+
|
926
|
+
def relpath(self, ref_dir) -> 'XlPath':
|
927
|
+
r""" 当前路径,相对于ref_dir的路径位置
|
928
|
+
|
929
|
+
>>> File('C:/a/b/c.txt').relpath('C:/a/')
|
930
|
+
'b/c.txt'
|
931
|
+
>>> File('C:/a/b\\c.txt').relpath('C:\\a/')
|
932
|
+
'b/c.txt'
|
933
|
+
|
934
|
+
>> File('C:/a/b/c.txt').relpath('D:/') # ValueError
|
935
|
+
"""
|
936
|
+
return XlPath(os.path.relpath(self, str(ref_dir)))
|
937
|
+
|
938
|
+
def as_windows_path(self):
|
939
|
+
""" 返回windows风格的路径,即使用\\分隔符 """
|
940
|
+
return self.as_posix().replace('/', '\\')
|
941
|
+
|
942
|
+
def __contains__(self, item):
|
943
|
+
""" 判断item的路径是否是在self里的,不考虑item是目录、文件,还是不存在文件的路径
|
944
|
+
以前缀判断为准,有需要的话请自行展开resolve、expanduser再判断
|
945
|
+
"""
|
946
|
+
if not self.is_file():
|
947
|
+
# 根据操作系统判断路径分隔符
|
948
|
+
separator = '\\' if os.name == 'nt' else '/'
|
949
|
+
|
950
|
+
# 获取路径对象的字符串形式
|
951
|
+
abs_path_str = str(self) + separator
|
952
|
+
item_str = str(XlPath(item))
|
953
|
+
|
954
|
+
# 判断路径字符串是否包含相对路径字符串
|
955
|
+
return item_str.startswith(abs_path_str) or abs_path_str == item_str
|
956
|
+
|
957
|
+
def get_total_lines(self, encoding='utf-8', skip_blank=False):
|
958
|
+
""" 统计文件的行数(注意会统计空行,所以在某些场合可能与预期理解的条目数不太一致)
|
959
|
+
|
960
|
+
:param str encoding: 文件编码,默认为'utf-8'
|
961
|
+
:param bool skip_blank: 是否跳过空白行,默认为True
|
962
|
+
:return: 文件的行数
|
963
|
+
"""
|
964
|
+
line_count = 0
|
965
|
+
with open(self, 'r', encoding=encoding) as file:
|
966
|
+
for line in file:
|
967
|
+
if skip_blank and not line.strip(): # 跳过空白行
|
968
|
+
continue
|
969
|
+
line_count += 1
|
970
|
+
return line_count
|
971
|
+
|
972
|
+
def yield_line(self, start=0, end=None, step=1, batch_size=None, encoding='utf-8'):
|
973
|
+
""" 返回指定区间的文件行
|
974
|
+
|
975
|
+
:param int start: 起始行,默认为0
|
976
|
+
:param int end: 结束行,默认为None(读取到文件末尾)
|
977
|
+
:param int step: 步长,默认为1
|
978
|
+
:param int batch_size: 每批返回的行数,如果为None,则逐行返回
|
979
|
+
"""
|
980
|
+
total_lines = None # 使用局部变量缓存总行数
|
981
|
+
# 处理负索引
|
982
|
+
if start < 0 or (end is not None and end < 0):
|
983
|
+
total_lines = total_lines or self.get_total_lines()
|
984
|
+
if start < 0:
|
985
|
+
start = total_lines + start
|
986
|
+
if end is not None and end < 0:
|
987
|
+
end = total_lines + end
|
988
|
+
|
989
|
+
with open(self, 'r', encoding=encoding) as file:
|
990
|
+
iterator = islice(file, start, end, step)
|
991
|
+
while True:
|
992
|
+
batch = list(islice(iterator, batch_size))
|
993
|
+
if not batch:
|
994
|
+
break
|
995
|
+
batch = [line.rstrip('\n') for line in batch] # 删除每行末尾的换行符
|
996
|
+
if batch_size is None:
|
997
|
+
yield from batch
|
998
|
+
else:
|
999
|
+
yield batch
|
1000
|
+
|
1001
|
+
def split_to_dir(self, lines_per_file, dst_dir=None, encoding='utf-8',
|
1002
|
+
filename_template="_{index}{suffix}"):
|
1003
|
+
""" 将文件按行拆分到多个子文件中
|
1004
|
+
|
1005
|
+
:param int lines_per_file: 打算拆分的每个新文件的行数
|
1006
|
+
:param str dst_dir: 目标目录,未输入的时候,输出到同stem名的目录下
|
1007
|
+
:param str filename_template: 文件名模板,可以包含 {stem}, {index} 和 {suffix} 占位符
|
1008
|
+
:return list: 拆分的文件路径列表
|
1009
|
+
拆分后文件名类似如下: 01.jsonl, 02.jsonl, ...
|
1010
|
+
"""
|
1011
|
+
# 1 检查输入参数
|
1012
|
+
if dst_dir is None:
|
1013
|
+
# 如果未提供目标目录,则拆分的文件保存到当前工作目录
|
1014
|
+
dst_dir = self.parent / f"{self.stem}"
|
1015
|
+
else:
|
1016
|
+
# 如果提供了目标目录,将拆分的文件保存到目标目录
|
1017
|
+
dst_dir = XlPath(dst_dir)
|
1018
|
+
|
1019
|
+
if dst_dir.is_dir():
|
1020
|
+
raise FileExistsError(f"目标目录已存在,若确定要重置目录,请先删除目录:{dst_dir}")
|
1021
|
+
|
1022
|
+
dst_dir.mkdir(parents=True, exist_ok=True)
|
1023
|
+
|
1024
|
+
# 2 拆分文件
|
1025
|
+
split_files = [] # 用于保存拆分的文件路径
|
1026
|
+
outfile = None
|
1027
|
+
filename_format = "{:04d}"
|
1028
|
+
outfile_index = 0
|
1029
|
+
line_counter = 0
|
1030
|
+
suffix = self.suffix
|
1031
|
+
|
1032
|
+
with open(self, 'r', encoding=encoding) as f:
|
1033
|
+
for line in f:
|
1034
|
+
if line_counter % lines_per_file == 0:
|
1035
|
+
if outfile is not None:
|
1036
|
+
outfile.close()
|
1037
|
+
outfile_path = dst_dir / f"{self.stem}_{filename_format.format(outfile_index)}{suffix}"
|
1038
|
+
outfile = open(outfile_path, 'w', encoding='utf-8')
|
1039
|
+
split_files.append(outfile_path) # 先占位,后面再填充
|
1040
|
+
outfile_index += 1
|
1041
|
+
outfile.write(line)
|
1042
|
+
line_counter += 1
|
1043
|
+
|
1044
|
+
if outfile is not None:
|
1045
|
+
outfile.close()
|
1046
|
+
|
1047
|
+
# 3 重新设置文件名的对齐宽度
|
1048
|
+
new_filename_format = "{:0" + str(len(str(len(split_files)))) + "d}"
|
1049
|
+
for i, old_file in enumerate(split_files):
|
1050
|
+
new_name = dst_dir / filename_template.format(stem=self.stem,
|
1051
|
+
index=new_filename_format.format(i),
|
1052
|
+
suffix=suffix)
|
1053
|
+
os.rename(old_file, new_name)
|
1054
|
+
split_files[i] = new_name
|
1055
|
+
|
1056
|
+
# 返回拆分的文件路径列表
|
1057
|
+
return split_files
|
1058
|
+
|
1059
|
+
def merge_from_files(self, files,
|
1060
|
+
ignore_empty_lines_between_files=False,
|
1061
|
+
encoding='utf-8'):
|
1062
|
+
""" 将多个文件合并到一个文件中
|
1063
|
+
|
1064
|
+
:param list files: 要合并的文件列表
|
1065
|
+
:param bool ignore_empty_lines_between_files: 是否忽略文件间的空行
|
1066
|
+
:param str encoding: 文件编码,默认为'utf-8'
|
1067
|
+
:return XlPath: 合并后的文件路径
|
1068
|
+
"""
|
1069
|
+
# 合并文件
|
1070
|
+
prev_line_end_with_newline = True # 记录上一次text的最后一个字符是否为'\n'
|
1071
|
+
with open(self, 'w', encoding=encoding) as outfile:
|
1072
|
+
for i, file in enumerate(files):
|
1073
|
+
file = XlPath(file)
|
1074
|
+
text = file.read_text(encoding=encoding)
|
1075
|
+
if ignore_empty_lines_between_files:
|
1076
|
+
text = text.rstrip('\n')
|
1077
|
+
if i > 0 and not prev_line_end_with_newline and text != '':
|
1078
|
+
outfile.write('\n')
|
1079
|
+
outfile.write(text)
|
1080
|
+
prev_line_end_with_newline = text.endswith('\n')
|
1081
|
+
|
1082
|
+
def merge_from_dir(self, src_dir, filename_template="_{index}{suffix}", encoding='utf-8'):
|
1083
|
+
""" 将目录中的多个文件合并到一个文件中
|
1084
|
+
|
1085
|
+
:param str src_dir: 要合并的文件所在的目录
|
1086
|
+
:param str filename_template: 文件名模板,可以包含 {stem}, {index} 和 {suffix} 占位符
|
1087
|
+
:param str encoding: 文件编码,默认为'utf-8'
|
1088
|
+
:return XlPath: 合并后的文件路径
|
1089
|
+
"""
|
1090
|
+
src_dir = XlPath(src_dir)
|
1091
|
+
stem = src_dir.name
|
1092
|
+
|
1093
|
+
pattern = filename_template.format(stem=stem, index=r"(\d+)", suffix=".*")
|
1094
|
+
files = [file for file in src_dir.iterdir() if re.match(pattern, file.name)] # 获取目录中符合模式的文件
|
1095
|
+
|
1096
|
+
self.merge_from_files(files, ignore_empty_lines_between_files=True, encoding=encoding)
|
1097
|
+
|
1098
|
+
def __1_read_write(self):
|
1099
|
+
""" 参考标准库的
|
1100
|
+
read_bytes、read_text
|
1101
|
+
write_bytes、write_text
|
1102
|
+
"""
|
1103
|
+
pass
|
1104
|
+
|
1105
|
+
def read_text(self, encoding='utf8', errors='strict', return_mode: bool = False):
|
1106
|
+
"""
|
1107
|
+
:param encoding: 效率拷贝,默认是设成utf8,但也可以设成None变成自动识别编码
|
1108
|
+
"""
|
1109
|
+
if not encoding:
|
1110
|
+
result = charset_normalizer.from_path(self, cp_isolation=('utf_8', 'gbk', 'utf_16'))
|
1111
|
+
best_match = result.best()
|
1112
|
+
s = str(best_match)
|
1113
|
+
encoding = best_match.encoding
|
1114
|
+
else:
|
1115
|
+
with open(self, 'r', encoding=encoding, errors=errors) as f:
|
1116
|
+
s = f.read()
|
1117
|
+
|
1118
|
+
# 如果用\r\n作为换行符会有一些意外不好处理
|
1119
|
+
if '\r' in s:
|
1120
|
+
s = s.replace('\r\n', '\n')
|
1121
|
+
|
1122
|
+
if return_mode:
|
1123
|
+
return s, encoding
|
1124
|
+
else:
|
1125
|
+
return s
|
1126
|
+
|
1127
|
+
def read_text2(self):
|
1128
|
+
""" 智能识别编码的文本读取,这里收集了我见过的一些常见类型 """
|
1129
|
+
for encoding in ['utf8',
|
1130
|
+
'gbk',
|
1131
|
+
'gb18030',
|
1132
|
+
'utf_16',
|
1133
|
+
'cp932', # 日文,Shift-JIS
|
1134
|
+
'Big5', # 繁体字,Big5
|
1135
|
+
'big5hkscs', # 繁体字
|
1136
|
+
]:
|
1137
|
+
try:
|
1138
|
+
content = self.read_text(encoding=encoding)
|
1139
|
+
return content, encoding
|
1140
|
+
except (UnicodeDecodeError, UnicodeError):
|
1141
|
+
continue
|
1142
|
+
|
1143
|
+
def readlines_batch(self, batch_size, *, encoding='utf8'):
|
1144
|
+
""" 将文本行打包,每次返回一个批次多行数据
|
1145
|
+
|
1146
|
+
python的io.IOBase.readlines有个hint参数,不是预期的读取几行的功能,所以这里重点是扩展了一个readlines的功能
|
1147
|
+
|
1148
|
+
:param batch_size: 默认每次获取一行内容,可以设参数,每次返回多行内容
|
1149
|
+
如果遍历每次只获取一行,一般不用这个接口,直接对open得到的文件句柄f操作就行了
|
1150
|
+
|
1151
|
+
:return: list[str]
|
1152
|
+
注意返回的每行str,末尾都带'\n'
|
1153
|
+
但最后一行视情况可能有\n,可能没有\n
|
1154
|
+
|
1155
|
+
注,开发指南:不然扩展支持batch_size=-1获取所有数据
|
1156
|
+
"""
|
1157
|
+
f = open(self, 'r', encoding=encoding)
|
1158
|
+
return chunked(f, batch_size)
|
1159
|
+
|
1160
|
+
def write_text(self, data, encoding='utf8', mode='w', errors=None, newline=None):
|
1161
|
+
with open(self, mode, encoding=encoding, errors=errors, newline=newline) as f:
|
1162
|
+
return f.write(data)
|
1163
|
+
|
1164
|
+
def write_text_unix(self, data, encoding='utf8', mode='w', errors=None, newline='\n'):
|
1165
|
+
with open(self, mode, encoding=encoding, errors=errors, newline=newline) as f:
|
1166
|
+
return f.write(data)
|
1167
|
+
|
1168
|
+
def read_pkl(self):
|
1169
|
+
with open(self, 'rb') as f:
|
1170
|
+
return pickle.load(f)
|
1171
|
+
|
1172
|
+
def write_pkl(self, data):
|
1173
|
+
with open(self, 'wb') as f:
|
1174
|
+
pickle.dump(data, f)
|
1175
|
+
|
1176
|
+
def read_json(self, encoding='utf8', *, errors='strict', return_mode: bool = False):
|
1177
|
+
"""
|
1178
|
+
|
1179
|
+
Args:
|
1180
|
+
encoding: 可以主动指定编码,否则默认会自动识别编码
|
1181
|
+
return_mode: 默认只返回读取的数据
|
1182
|
+
开启后,得到更丰富的返回信息: data, encoding
|
1183
|
+
该功能常用在需要自动识别编码,重写回文件时使用相同的编码格式
|
1184
|
+
Returns:
|
1185
|
+
|
1186
|
+
"""
|
1187
|
+
s, encoding = self.read_text(encoding=encoding, errors=errors, return_mode=True)
|
1188
|
+
try:
|
1189
|
+
data = ujson.loads(s)
|
1190
|
+
except ValueError: # ujson会有些不太标准的情况处理不了
|
1191
|
+
data = json.loads(s)
|
1192
|
+
|
1193
|
+
if return_mode:
|
1194
|
+
return data, encoding
|
1195
|
+
else:
|
1196
|
+
return data
|
1197
|
+
|
1198
|
+
def write_json(self, data, encoding='utf8', **kwargs):
|
1199
|
+
with open(self, 'w', encoding=encoding) as f:
|
1200
|
+
DictTool.ior(kwargs, {'ensure_ascii': False})
|
1201
|
+
json.dump(data, f, **kwargs)
|
1202
|
+
|
1203
|
+
def read_jsonl(self, encoding='utf8', max_items=None, *,
|
1204
|
+
errors='strict', return_mode=0, batch_size=None):
|
1205
|
+
""" 从文件中读取JSONL格式的数据
|
1206
|
+
|
1207
|
+
:param str encoding: 文件编码格式,默认为utf8
|
1208
|
+
:param str errors: 读取文件时的错误处理方式,默认为strict
|
1209
|
+
:param bool return_mode: 是否返回文件编码格式,默认为False
|
1210
|
+
0, 读取全量数据返回
|
1211
|
+
1,返回文件编码格式
|
1212
|
+
:param int max_items: 限制读取的条目数,默认为None,表示读取所有条目
|
1213
|
+
:param int batch_size:
|
1214
|
+
默认为None,表示一次性读取所有数据
|
1215
|
+
如果设置了数值,则会流式读取,常用于太大,超过内存大小等的jsonl文件读取
|
1216
|
+
注意如果设置了大小,只是底层每次一批读取的大小,但返回的data仍然是一维的数据格式迭代器
|
1217
|
+
:return: 返回读取到的数据列表,如果return_mode为True,则同时返回文件编码格式
|
1218
|
+
|
1219
|
+
>> read_jsonl('data.jsonl', max_items=10) # 读取前10条数据
|
1220
|
+
"""
|
1221
|
+
if batch_size is None:
|
1222
|
+
s, encoding = self.read_text(encoding=encoding, errors=errors, return_mode=True)
|
1223
|
+
|
1224
|
+
data = []
|
1225
|
+
# todo 这一步可能不够严谨,不同的操作系统文件格式不同。但使用splitlines也不太好,在数据含有NEL等特殊字符时会多换行。
|
1226
|
+
for line in s.split('\n'):
|
1227
|
+
if line:
|
1228
|
+
try: # 注意,这里可能会有数据读取失败
|
1229
|
+
data.append(json.loads(line))
|
1230
|
+
except json.decoder.JSONDecodeError:
|
1231
|
+
pass
|
1232
|
+
# 如果达到了限制的条目数,就停止读取
|
1233
|
+
if max_items is not None and len(data) >= max_items:
|
1234
|
+
break
|
1235
|
+
else:
|
1236
|
+
def get_data():
|
1237
|
+
for batch in self.yield_line(batch_size=batch_size, encoding=encoding):
|
1238
|
+
for line in batch:
|
1239
|
+
try: # 注意,这里可能会有数据读取失败
|
1240
|
+
yield json.loads(line)
|
1241
|
+
except json.decoder.JSONDecodeError:
|
1242
|
+
pass
|
1243
|
+
|
1244
|
+
data = get_data()
|
1245
|
+
|
1246
|
+
if return_mode:
|
1247
|
+
return data, encoding
|
1248
|
+
else:
|
1249
|
+
return data
|
1250
|
+
|
1251
|
+
def write_jsonl(self, list_data, ensure_ascii=False, default=None, mode='w', errors=None):
|
1252
|
+
""" 由于这种格式主要是跟商汤这边对接,就尽量跟它们的格式进行兼容 """
|
1253
|
+
content = '\n'.join([json.dumps(x, ensure_ascii=ensure_ascii, default=default) for x in list_data])
|
1254
|
+
self.write_text_unix(content + '\n', mode=mode, errors=errors)
|
1255
|
+
|
1256
|
+
def add_json_line(self, data, ensure_ascii=False, default=None, mode='a'):
|
1257
|
+
""" 在文件末尾添加一行JSON数据 """
|
1258
|
+
content = json.dumps(data, ensure_ascii=ensure_ascii, default=default)
|
1259
|
+
self.write_text_unix(content + '\n', mode=mode)
|
1260
|
+
|
1261
|
+
def read_csv(self, encoding='utf8', *, errors='strict', return_mode: bool = False,
|
1262
|
+
delimiter=',', quotechar='"', **kwargs):
|
1263
|
+
"""
|
1264
|
+
:return:
|
1265
|
+
data,n行m列的list
|
1266
|
+
"""
|
1267
|
+
import csv
|
1268
|
+
|
1269
|
+
s, encoding = self.read_text(encoding=encoding, errors=errors, return_mode=True)
|
1270
|
+
data = list(csv.reader(s.splitlines(), delimiter=delimiter, quotechar=quotechar, **kwargs))
|
1271
|
+
|
1272
|
+
if return_mode:
|
1273
|
+
return data, encoding
|
1274
|
+
else:
|
1275
|
+
return data
|
1276
|
+
|
1277
|
+
def read_yaml(self, encoding='utf8', *, errors='strict', rich_return=False):
|
1278
|
+
s, encoding = self.read_text(encoding=encoding, errors=errors, return_mode=True)
|
1279
|
+
data = yaml.safe_load(s)
|
1280
|
+
|
1281
|
+
if rich_return:
|
1282
|
+
return data, encoding
|
1283
|
+
else:
|
1284
|
+
return data
|
1285
|
+
|
1286
|
+
def write_yaml(self, data, encoding='utf8', *, sort_keys=False, **kwargs):
|
1287
|
+
with open(self, 'w', encoding=encoding) as f:
|
1288
|
+
yaml.dump(data, f, sort_keys=sort_keys, **kwargs)
|
1289
|
+
|
1290
|
+
def read_auto(self, *args, **kwargs):
|
1291
|
+
""" 根据文件后缀自动识别读取函数 """
|
1292
|
+
if self.is_file(): # 如果存在这样的文件,那就读取文件内容
|
1293
|
+
# 获得文件扩展名,并统一转成小写
|
1294
|
+
mode = self.suffix.lower()[1:]
|
1295
|
+
read_func = getattr(self, 'read_' + mode, None)
|
1296
|
+
if read_func:
|
1297
|
+
return read_func(*args, **kwargs)
|
1298
|
+
elif mode in ('jpg', 'png'): # 常见的一些二进制数据
|
1299
|
+
return self.read_bytes()
|
1300
|
+
else:
|
1301
|
+
return self.read_text(*args, **kwargs)
|
1302
|
+
else: # 非文件对象
|
1303
|
+
raise FileNotFoundError(f'{self} 文件不存在,无法读取。')
|
1304
|
+
|
1305
|
+
def write_auto(self, data, *args, if_exists=None, **kwargs):
|
1306
|
+
""" 根据文件后缀自动识别写入函数 """
|
1307
|
+
self.parent.mkdir(exist_ok=True, parents=True)
|
1308
|
+
mode = self.suffix.lower()[1:]
|
1309
|
+
write_func = getattr(self, 'write_' + mode, None)
|
1310
|
+
if self.exist_preprcs(if_exists):
|
1311
|
+
if write_func:
|
1312
|
+
return write_func(data, *args, **kwargs)
|
1313
|
+
else:
|
1314
|
+
return self.write_text(str(data), *args, **kwargs)
|
1315
|
+
return self
|
1316
|
+
|
1317
|
+
def __2_glob(self):
|
1318
|
+
""" 类型判断、glob系列 """
|
1319
|
+
pass
|
1320
|
+
|
1321
|
+
def ____1_常用glob(self):
|
1322
|
+
pass
|
1323
|
+
|
1324
|
+
def glob(self, pattern):
|
1325
|
+
# TODO 加正则匹配模式
|
1326
|
+
if isinstance(pattern, str):
|
1327
|
+
return super(XlPath, self).glob(pattern)
|
1328
|
+
elif isinstance(pattern, (list, tuple)):
|
1329
|
+
from itertools import chain
|
1330
|
+
ls = [super(XlPath, self).glob(x) for x in pattern]
|
1331
|
+
# 去重
|
1332
|
+
exists = set()
|
1333
|
+
files = []
|
1334
|
+
for f in chain(*ls):
|
1335
|
+
k = f.as_posix()
|
1336
|
+
if k not in exists:
|
1337
|
+
exists.add(k)
|
1338
|
+
files.append(f)
|
1339
|
+
return files
|
1340
|
+
|
1341
|
+
def glob_files(self, pattern='*'):
|
1342
|
+
for f in self.glob(pattern):
|
1343
|
+
if f.is_file():
|
1344
|
+
yield f
|
1345
|
+
|
1346
|
+
def rglob_files(self, pattern='*'):
|
1347
|
+
for f in self.rglob(pattern):
|
1348
|
+
if f.is_file():
|
1349
|
+
yield f
|
1350
|
+
|
1351
|
+
def glob_dirs(self, pattern='*'):
|
1352
|
+
for f in self.glob(pattern):
|
1353
|
+
if f.is_dir():
|
1354
|
+
yield f
|
1355
|
+
|
1356
|
+
def rglob_dirs(self, pattern='*'):
|
1357
|
+
for f in self.rglob(pattern):
|
1358
|
+
if f.is_dir():
|
1359
|
+
yield f
|
1360
|
+
|
1361
|
+
def glob_stems(self):
|
1362
|
+
""" 按照文件的stem分组读取
|
1363
|
+
|
1364
|
+
:return: 返回格式类似 {'stem1': [suffix1, suffix2, ...], 'stem2': [suffix1, suffix2, ...], ...}
|
1365
|
+
"""
|
1366
|
+
from collections import defaultdict
|
1367
|
+
d = defaultdict(set)
|
1368
|
+
for f in self.glob_files():
|
1369
|
+
d[f.stem].add(f.suffix)
|
1370
|
+
return d
|
1371
|
+
|
1372
|
+
def glob_suffixs(self):
|
1373
|
+
""" 判断目录下有哪些扩展名的文件 """
|
1374
|
+
suffixs = set()
|
1375
|
+
for f in self.glob_files():
|
1376
|
+
suffixs.add(f.suffix)
|
1377
|
+
return suffixs
|
1378
|
+
|
1379
|
+
def ____2_定制glob(self):
|
1380
|
+
pass
|
1381
|
+
|
1382
|
+
def is_image(self):
|
1383
|
+
return self.is_file() and filetype.is_image(self)
|
1384
|
+
|
1385
|
+
def _glob_images(self, glob_func, pattern):
|
1386
|
+
""" 在满足glob规则基础上,后缀还必须是合法的图片格式后缀
|
1387
|
+
"""
|
1388
|
+
suffixs = {'png', 'jpg', 'jpeg', 'bmp', 'webp', 'gif'}
|
1389
|
+
for f in glob_func(pattern):
|
1390
|
+
if f.is_file() and f.suffix[1:].lower() in suffixs:
|
1391
|
+
yield f
|
1392
|
+
|
1393
|
+
def glob_images(self, pattern='*'):
|
1394
|
+
""" 按照文件后缀,获取所有图片文件 """
|
1395
|
+
return self._glob_images(self.glob, pattern)
|
1396
|
+
|
1397
|
+
def rglob_images(self, pattern='*'):
|
1398
|
+
return self._glob_images(self.rglob, pattern)
|
1399
|
+
|
1400
|
+
def ____3_xglob(self):
|
1401
|
+
""" xglob系列,都是按照文件的实际内容,进行类型检索遍历的 """
|
1402
|
+
|
1403
|
+
def xglob_images(self, pattern='*'):
|
1404
|
+
""" 按照文件实际内容,获取所有图片文件 """
|
1405
|
+
for f in self.glob(pattern):
|
1406
|
+
if f.is_file() and filetype.is_image(f):
|
1407
|
+
yield f
|
1408
|
+
|
1409
|
+
def xglob_videos(self, pattern='*'):
|
1410
|
+
""" 按照文件实际内容,获取所有图片文件 """
|
1411
|
+
for f in self.glob(pattern):
|
1412
|
+
if f.is_file() and filetype.is_video(f):
|
1413
|
+
yield f
|
1414
|
+
|
1415
|
+
def xglob_archives(self, pattern='*'):
|
1416
|
+
""" 找出所有压缩文件
|
1417
|
+
|
1418
|
+
只找通用意义上的压缩包,不找docx等这种形式的文件
|
1419
|
+
"""
|
1420
|
+
for f in self.glob(pattern):
|
1421
|
+
if f.is_file() and filetype.is_archive(f):
|
1422
|
+
if f.suffix in {'.pdf', '.docx', '.xlsx', '.pptx'}:
|
1423
|
+
continue
|
1424
|
+
yield f
|
1425
|
+
|
1426
|
+
def __3_文件基础操作(self):
|
1427
|
+
""" 复制、删除、移动等操作 """
|
1428
|
+
pass
|
1429
|
+
|
1430
|
+
def exist_preprcs(self, if_exists=None):
|
1431
|
+
""" 从旧版File机制复制过来的函数
|
1432
|
+
|
1433
|
+
这个实际上是在做copy等操作前,如果目标文件已存在,需要预先删除等的预处理
|
1434
|
+
并返回判断,是否需要执行下一步操作
|
1435
|
+
|
1436
|
+
有时候情况比较复杂,process无法满足需求时,可以用exist_preprcs这个底层函数协助
|
1437
|
+
|
1438
|
+
:param if_exists:
|
1439
|
+
None: 不做任何处理,直接运行,依赖于功能本身是否有覆盖写入机制
|
1440
|
+
'error': 如果要替换的目标文件已经存在,则报错
|
1441
|
+
'replace': 把存在的文件先删除
|
1442
|
+
本来是叫'delete'更准确的,但是考虑用户理解,
|
1443
|
+
一般都是用在文件替换场合,叫成'delete'会非常怪异,带来不必要的困扰、误解
|
1444
|
+
所以还是决定叫'replace'
|
1445
|
+
'skip': 不执行后续功能
|
1446
|
+
'backup': 先做备份 (对原文件先做一个备份)
|
1447
|
+
"""
|
1448
|
+
need_run = True
|
1449
|
+
if self.exists():
|
1450
|
+
if if_exists is None:
|
1451
|
+
return need_run
|
1452
|
+
elif if_exists == 'error':
|
1453
|
+
raise FileExistsError(f'目标文件已存在: {self}')
|
1454
|
+
elif if_exists == 'replace':
|
1455
|
+
self.delete()
|
1456
|
+
elif if_exists == 'skip':
|
1457
|
+
need_run = False
|
1458
|
+
elif if_exists == 'backup':
|
1459
|
+
self.backup(move=True)
|
1460
|
+
else:
|
1461
|
+
raise ValueError(f'{if_exists}')
|
1462
|
+
return need_run
|
1463
|
+
|
1464
|
+
def copy(self, dst, if_exists=None):
|
1465
|
+
""" 用于一般的文件、目录拷贝
|
1466
|
+
|
1467
|
+
不返回结果文件,是因为 if_exists 的逻辑比较特殊,比如skip的时候,这里不好实现返回的是最后的目标文件
|
1468
|
+
"""
|
1469
|
+
if not self.exists():
|
1470
|
+
return
|
1471
|
+
|
1472
|
+
dst = XlPath(dst)
|
1473
|
+
if dst.exist_preprcs(if_exists):
|
1474
|
+
if self.is_file():
|
1475
|
+
return shutil.copy2(self, dst)
|
1476
|
+
else:
|
1477
|
+
return shutil.copytree(self, dst)
|
1478
|
+
|
1479
|
+
def move(self, dst, *, cross_disk=False, if_exists=None):
|
1480
|
+
"""
|
1481
|
+
:param cross_disk: 是否可能涉及跨磁盘操作
|
1482
|
+
"""
|
1483
|
+
if not self.exists():
|
1484
|
+
return self
|
1485
|
+
|
1486
|
+
if cross_disk: # 显式设置跨磁盘操作
|
1487
|
+
dst = self.copy(dst, if_exists=if_exists)
|
1488
|
+
self.delete()
|
1489
|
+
return dst
|
1490
|
+
|
1491
|
+
try:
|
1492
|
+
dst = XlPath(dst)
|
1493
|
+
if self == dst:
|
1494
|
+
# 同一个文件,可能是调整了大小写名称
|
1495
|
+
if self.as_posix() != dst.as_posix():
|
1496
|
+
tmp = self.tempfile(dir=self.parent) # self不一定是file,也可能是dir,但这个名称通用
|
1497
|
+
self.rename(tmp)
|
1498
|
+
self.delete()
|
1499
|
+
tmp.rename(dst)
|
1500
|
+
elif dst.exist_preprcs(if_exists):
|
1501
|
+
self.rename(dst)
|
1502
|
+
except OSError:
|
1503
|
+
# 有可能是跨磁盘操作,这个时候就只能先拷贝再删除了
|
1504
|
+
dst = self.copy(dst, if_exists=if_exists)
|
1505
|
+
self.delete()
|
1506
|
+
return dst
|
1507
|
+
|
1508
|
+
def rename2(self, new_name, if_exists=None):
|
1509
|
+
""" 相比原版的rename,搞了更多骚操作,但性能也会略微下降,所以重写一个功能名
|
1510
|
+
|
1511
|
+
240416周二12:49,这个接口将真的只做重命名,不做移动!所以将会不再支持dst中出现"/"路径配置
|
1512
|
+
"""
|
1513
|
+
if not self.exists():
|
1514
|
+
return self
|
1515
|
+
|
1516
|
+
if not isinstance(new_name, str):
|
1517
|
+
raise ValueError(f'rename2只能做重命名操作,目标路径必须是一个str')
|
1518
|
+
elif '/' in new_name:
|
1519
|
+
raise ValueError(f'rename2只能做重命名操作,目标路径中不能包含"/"')
|
1520
|
+
elif '\\' in new_name:
|
1521
|
+
raise ValueError(f'rename2只能做重命名操作,目标路径中不能包含"\\"')
|
1522
|
+
|
1523
|
+
if self.name == new_name: # 没有修改名称,跟原来相同
|
1524
|
+
return self
|
1525
|
+
|
1526
|
+
dst = self.parent / new_name
|
1527
|
+
if self == dst:
|
1528
|
+
# 同一个文件,可能是调整了大小写名称
|
1529
|
+
if self.as_posix() != dst.as_posix():
|
1530
|
+
tmp = self.tempfile(dir=self.parent) # self不一定是file,也可能是dir,但这个名称通用
|
1531
|
+
self.rename(tmp)
|
1532
|
+
self.delete()
|
1533
|
+
tmp.rename(dst)
|
1534
|
+
elif dst.exist_preprcs(if_exists):
|
1535
|
+
self.rename(dst)
|
1536
|
+
return dst
|
1537
|
+
|
1538
|
+
def rename_stem(self, stem, if_exists=None):
|
1539
|
+
""" 重命名文件的stem """
|
1540
|
+
return self.rename2(stem + self.suffix, if_exists)
|
1541
|
+
|
1542
|
+
def rename_suffix(self, suffix, if_exists=None):
|
1543
|
+
""" 重命名文件的suffix """
|
1544
|
+
return self.rename2(self.stem + suffix, if_exists)
|
1545
|
+
|
1546
|
+
def delete(self):
|
1547
|
+
if self.is_file():
|
1548
|
+
os.remove(self)
|
1549
|
+
elif self.is_dir():
|
1550
|
+
shutil.rmtree(self)
|
1551
|
+
|
1552
|
+
def backup(self, tail=None, if_exists='replace', move=False):
|
1553
|
+
r""" 对文件末尾添加时间戳备份,也可以使用自定义标记tail
|
1554
|
+
|
1555
|
+
:param tail: 自定义添加后缀
|
1556
|
+
tail为None时,默认添加特定格式的时间戳
|
1557
|
+
:param if_exists: 备份的目标文件名存在时的处理方案
|
1558
|
+
这个概率非常小,真遇到,先把已存在的删掉,重新写入一个是可以接受的
|
1559
|
+
:param move: 是否删除原始文件
|
1560
|
+
|
1561
|
+
# TODO:有个小bug,如果在不同时间实际都是相同一个文件,也会被不断反复备份
|
1562
|
+
# 如果想解决这个,就要读取目录下最近的备份文件对比内容了
|
1563
|
+
"""
|
1564
|
+
from datetime import datetime
|
1565
|
+
|
1566
|
+
# 1 判断自身文件是否存在
|
1567
|
+
if not self:
|
1568
|
+
return None
|
1569
|
+
|
1570
|
+
# 2 计算出新名称
|
1571
|
+
if not tail:
|
1572
|
+
tail = datetime.fromtimestamp(self.mtime()).strftime(' %y%m%d-%H%M%S') # 时间戳
|
1573
|
+
name, ext = os.path.splitext(str(self))
|
1574
|
+
dst = name + tail + ext
|
1575
|
+
|
1576
|
+
# 3 备份就是特殊的copy操作
|
1577
|
+
if move:
|
1578
|
+
return self.move(dst, if_exists)
|
1579
|
+
else:
|
1580
|
+
return self.copy(dst, if_exists)
|
1581
|
+
|
1582
|
+
def __4_重复文件相关功能(self):
|
1583
|
+
""" 检查目录里的各种文件情况 """
|
1584
|
+
|
1585
|
+
def glob_repeat_files(self, pattern='*', *, sort_mode='count', print_mode=False,
|
1586
|
+
files=None, hash_func=None):
|
1587
|
+
""" 返回重复的文件组
|
1588
|
+
|
1589
|
+
:param files: 直接指定候选文件清单,此时pattern默认失效
|
1590
|
+
:param hash_func: hash规则,默认使用etag规则
|
1591
|
+
:param sort_mode:
|
1592
|
+
count: 按照重复的文件数量从多到少排序
|
1593
|
+
size: 按照空间总占用量从大到小排序
|
1594
|
+
:return: [(etag, files, per_file_size), ...]
|
1595
|
+
"""
|
1596
|
+
# 0 文件清单和hash方法
|
1597
|
+
if files is None:
|
1598
|
+
files = list(self.glob_files(pattern))
|
1599
|
+
|
1600
|
+
if hash_func is None:
|
1601
|
+
def hash_func(f):
|
1602
|
+
return get_etag(str(f))
|
1603
|
+
|
1604
|
+
# 1 获取所有etag,这一步比较费时
|
1605
|
+
hash2files = defaultdict(list)
|
1606
|
+
|
1607
|
+
for f in tqdm(files, desc='get etags', disable=not print_mode):
|
1608
|
+
etag = hash_func(f)
|
1609
|
+
hash2files[etag].append(f)
|
1610
|
+
|
1611
|
+
# 2 转格式,排序
|
1612
|
+
hash2files = [(k, vs, vs[0].size()) for k, vs in hash2files.items() if len(vs) > 1]
|
1613
|
+
if sort_mode == 'count':
|
1614
|
+
hash2files.sort(key=lambda x: (-len(x[1]), -len(x[1]) * x[2]))
|
1615
|
+
elif sort_mode == 'size':
|
1616
|
+
hash2files.sort(key=lambda x: (-len(x[1]) * x[2], -len(x[1])))
|
1617
|
+
|
1618
|
+
# 3 返回每一组数据
|
1619
|
+
return hash2files
|
1620
|
+
|
1621
|
+
def delete_repeat_files(self, pattern='*', *, sort_mode='count', print_mode=True, debug=False,
|
1622
|
+
files=None, hash_func=None):
|
1623
|
+
"""
|
1624
|
+
:param debug:
|
1625
|
+
True,只是输出检查清单,不做操作
|
1626
|
+
False, 保留第1个文件,删除其他文件
|
1627
|
+
TODO,添加其他删除模式
|
1628
|
+
:param print_mode:
|
1629
|
+
0,不输出
|
1630
|
+
'str',普通文本输出(即返回的msg)
|
1631
|
+
'html',TODO 支持html富文本显示,带超链接
|
1632
|
+
"""
|
1633
|
+
from humanfriendly import format_size
|
1634
|
+
|
1635
|
+
def printf(*args, **kwargs):
|
1636
|
+
if print_mode == 'html':
|
1637
|
+
raise NotImplementedError
|
1638
|
+
elif print_mode:
|
1639
|
+
print(*args, **kwargs)
|
1640
|
+
msg.append(' '.join(args))
|
1641
|
+
|
1642
|
+
fmtsize = lambda x: format_size(x, binary=True)
|
1643
|
+
|
1644
|
+
msg = []
|
1645
|
+
files = self.glob_repeat_files(pattern, sort_mode=sort_mode, print_mode=print_mode,
|
1646
|
+
files=files, hash_func=hash_func)
|
1647
|
+
for i, (etag, files, _size) in enumerate(files, start=1):
|
1648
|
+
n = len(files)
|
1649
|
+
printf(f'{i}、{etag}\t{fmtsize(_size)} × {n} ≈ {fmtsize(_size * n)}')
|
1650
|
+
|
1651
|
+
for j, f in enumerate(files, start=1):
|
1652
|
+
if debug:
|
1653
|
+
printf(f'\t{f.relpath(self)}')
|
1654
|
+
else:
|
1655
|
+
if j == 1:
|
1656
|
+
printf(f'\t{f.relpath(self)}')
|
1657
|
+
else:
|
1658
|
+
f.delete()
|
1659
|
+
printf(f'\t{f.relpath(self)}\tdelete')
|
1660
|
+
if print_mode:
|
1661
|
+
printf()
|
1662
|
+
|
1663
|
+
return msg
|
1664
|
+
|
1665
|
+
def check_repeat_files(self, pattern='**/*', **kwargs):
|
1666
|
+
if 'debug' not in kwargs:
|
1667
|
+
kwargs['debug'] = True
|
1668
|
+
return self.delete_repeat_files(pattern, **kwargs)
|
1669
|
+
|
1670
|
+
def check_repeat_name_files(self, pattern='**/*', **kwargs):
|
1671
|
+
if 'hash_func' not in kwargs:
|
1672
|
+
kwargs['hash_func'] = lambda p: p.name.lower()
|
1673
|
+
return self.check_repeat_files(pattern, **kwargs)
|
1674
|
+
|
1675
|
+
def __5_文件后缀相关功能(self):
|
1676
|
+
""" 检查目录里的各种文件情况 """
|
1677
|
+
|
1678
|
+
def refine_files_suffix(self, pattern='*', *, print_mode=True, if_exists=None, debug=False):
|
1679
|
+
""" 优化文件的后缀名 """
|
1680
|
+
j = 1
|
1681
|
+
for i, f1 in enumerate(self.glob_files(pattern), start=1):
|
1682
|
+
suffix1 = f1.suffix
|
1683
|
+
|
1684
|
+
suffix2 = suffix1.lower()
|
1685
|
+
if suffix2 == '.jpeg':
|
1686
|
+
suffix2 = '.jpg'
|
1687
|
+
|
1688
|
+
if suffix1 != suffix2:
|
1689
|
+
f2 = f1.with_suffix(suffix2)
|
1690
|
+
|
1691
|
+
if print_mode:
|
1692
|
+
print(f'{i}/{j} {f1} -> {suffix2}')
|
1693
|
+
|
1694
|
+
if not debug:
|
1695
|
+
f1.rename2(f2, if_exists=if_exists)
|
1696
|
+
j += 1
|
1697
|
+
|
1698
|
+
def _check_faker_suffix(self, file_list):
|
1699
|
+
""" 检查文件扩展名是否匹配实际内容类型,并迭代文件列表进行处理。
|
1700
|
+
|
1701
|
+
:param file_list: 文件列表
|
1702
|
+
:return: 迭代器,产生文件路径和相应的信息
|
1703
|
+
"""
|
1704
|
+
for file_path in file_list:
|
1705
|
+
t = filetype.guess(file_path)
|
1706
|
+
if not t:
|
1707
|
+
continue
|
1708
|
+
|
1709
|
+
ext = '.' + t.extension
|
1710
|
+
ext0 = file_path.suffix
|
1711
|
+
if ext == ext0:
|
1712
|
+
continue
|
1713
|
+
elif ext == '.xls' and ext0 == '.et':
|
1714
|
+
continue
|
1715
|
+
|
1716
|
+
if ext0 in ('.docx', '.xlsx', '.pptx', '.xlsm'):
|
1717
|
+
ext0 = '.zip'
|
1718
|
+
elif ext0 in ('.JPG', '.jpeg'):
|
1719
|
+
ext0 = '.jpg'
|
1720
|
+
|
1721
|
+
if ext != ext0:
|
1722
|
+
yield file_path, ext
|
1723
|
+
|
1724
|
+
def xglob_faker_suffix_files(self, pattern='*'):
|
1725
|
+
""" 检查文件扩展名是不是跟实际内容类型不匹配,有问题
|
1726
|
+
|
1727
|
+
注:推荐先运行 refine_files_suffix,本函数是大小写敏感,并且不会区分jpeg和jpg
|
1728
|
+
"""
|
1729
|
+
return self._check_faker_suffix(self.glob_files(pattern))
|
1730
|
+
|
1731
|
+
def xglob_faker_suffix_images(self, pattern='*'):
|
1732
|
+
""" 只检查原本就是图片名称标记的文件的相关数据正误
|
1733
|
+
"""
|
1734
|
+
return self._check_faker_suffix(self.glob_images(pattern))
|
1735
|
+
|
1736
|
+
def rename_faker_suffix_files(self, pattern='*', *, print_mode=True, if_exists=None, debug=False):
|
1737
|
+
for i, (f1, suffix2) in enumerate(self.xglob_faker_suffix_files(pattern), start=1):
|
1738
|
+
if print_mode:
|
1739
|
+
print(f'{i}、{f1} -> {suffix2}')
|
1740
|
+
|
1741
|
+
if not debug:
|
1742
|
+
f2 = f1.with_suffix(suffix2)
|
1743
|
+
f1.rename2(f2, if_exists=if_exists)
|
1744
|
+
|
1745
|
+
def __6_文件夹分析诊断(self):
|
1746
|
+
pass
|
1747
|
+
|
1748
|
+
def check_size(self, return_mode='str'):
|
1749
|
+
import pandas as pd
|
1750
|
+
|
1751
|
+
msg = []
|
1752
|
+
file_sizes = {} # 缓存文件大小,避免重复计算
|
1753
|
+
suffix_counts = Counter()
|
1754
|
+
suffix_sizes = defaultdict(int)
|
1755
|
+
|
1756
|
+
dir_count, file_count = 0, 0
|
1757
|
+
for root, dirs, files in os.walk(self):
|
1758
|
+
dir_count += len(dirs)
|
1759
|
+
file_count += len(files)
|
1760
|
+
for file in files:
|
1761
|
+
file_size = os.path.getsize(os.path.join(root, file))
|
1762
|
+
file_sizes[(root, file)] = file_size
|
1763
|
+
|
1764
|
+
_, suffix = os.path.splitext(file)
|
1765
|
+
suffix_counts[suffix] += 1
|
1766
|
+
suffix_sizes[suffix] += file_size
|
1767
|
+
|
1768
|
+
sz = human_readable_size(sum(file_sizes.values()))
|
1769
|
+
# 这里的目录指"子目录"数,不包含self
|
1770
|
+
msg.append(f'一、目录数:{dir_count},文件数:{file_count},总大小:{sz}')
|
1771
|
+
|
1772
|
+
data = []
|
1773
|
+
for suffix, count in suffix_counts.most_common():
|
1774
|
+
size = suffix_sizes[suffix]
|
1775
|
+
data.append([suffix, count, size])
|
1776
|
+
data.sort(key=lambda x: (-x[2], -x[1], x[0])) # 先按文件数,再按文件名排序
|
1777
|
+
df = pd.DataFrame(data, columns=['suffix', 'count', 'size'])
|
1778
|
+
df['size'] = [human_readable_size(x) for x in df['size']]
|
1779
|
+
df.reset_index(inplace=True)
|
1780
|
+
df['index'] += 1
|
1781
|
+
msg.append('\n二、各后缀文件数')
|
1782
|
+
msg.append(df.to_string(index=False))
|
1783
|
+
|
1784
|
+
if return_mode == 'str':
|
1785
|
+
return '\n'.join(msg)
|
1786
|
+
elif return_mode == 'list':
|
1787
|
+
return msg
|
1788
|
+
else:
|
1789
|
+
return msg
|
1790
|
+
|
1791
|
+
def check_summary(self, print_mode=True, return_mode=False, **kwargs):
|
1792
|
+
if self.is_dir():
|
1793
|
+
res = self._check_dir_summary(print_mode, **kwargs)
|
1794
|
+
elif self.is_file():
|
1795
|
+
res = self._check_file_summary(print_mode, **kwargs)
|
1796
|
+
else:
|
1797
|
+
res = '文件不存在'
|
1798
|
+
print(res)
|
1799
|
+
|
1800
|
+
if return_mode:
|
1801
|
+
return res
|
1802
|
+
|
1803
|
+
def _check_file_summary(self, print_mode=True, **kwargs):
|
1804
|
+
""" 对文件进行通用的状态检查
|
1805
|
+
|
1806
|
+
:param bool print_mode: 是否将统计信息打印到控制台
|
1807
|
+
:return dict: 文件的统计信息
|
1808
|
+
"""
|
1809
|
+
file_summary = {}
|
1810
|
+
|
1811
|
+
# 文件大小
|
1812
|
+
file_summary['文件大小'] = self.size(human_readable=True)
|
1813
|
+
|
1814
|
+
# 文件行数
|
1815
|
+
file_summary['文件行数'] = self.get_total_lines()
|
1816
|
+
|
1817
|
+
# 文件修改时间
|
1818
|
+
mod_time_str = datetime.datetime.fromtimestamp(self.mtime()).strftime('%Y-%m-%d %H:%M:%S')
|
1819
|
+
file_summary['修改时间'] = mod_time_str
|
1820
|
+
|
1821
|
+
# 如果print_mode为True,则将统计信息打印到控制台
|
1822
|
+
if print_mode:
|
1823
|
+
for key, value in file_summary.items():
|
1824
|
+
print(f"{key}: {value}")
|
1825
|
+
|
1826
|
+
return file_summary
|
1827
|
+
|
1828
|
+
def _check_dir_summary(self, print_mode=True, hash_func=None, run_mode=31):
|
1829
|
+
""" 对文件夹情况进行通用的状态检查
|
1830
|
+
|
1831
|
+
:param hash_func: 可以传入自定义的hash函数,用于第四块的重复文件运算
|
1832
|
+
其实默认的get_etag就没啥问题,只是有时候为了性能考虑,可能会传入一个支持,提前有缓存知道etag的函数
|
1833
|
+
:param int run_mode: 只运行编号内的功能
|
1834
|
+
"""
|
1835
|
+
if not self.is_dir():
|
1836
|
+
return ''
|
1837
|
+
|
1838
|
+
def printf(s):
|
1839
|
+
if print_mode:
|
1840
|
+
print(s)
|
1841
|
+
msg.append(s)
|
1842
|
+
|
1843
|
+
# 一 目录大小,二 各后缀文件大小
|
1844
|
+
msg = []
|
1845
|
+
if run_mode & 1: # 1和2目前是绑定一起运行的
|
1846
|
+
printf('【' + self.as_posix() + '】目录检查')
|
1847
|
+
printf('\n'.join(self.check_size('list')))
|
1848
|
+
|
1849
|
+
# 三 重名文件
|
1850
|
+
if run_mode & 2:
|
1851
|
+
printf('\n三、重名文件(忽略大小写,跨目录检查name重复情况)')
|
1852
|
+
printf('\n'.join(self.check_repeat_name_files(print_mode=False)))
|
1853
|
+
|
1854
|
+
# 四 重复文件
|
1855
|
+
if run_mode & 4:
|
1856
|
+
printf('\n四、重复文件(etag相同)')
|
1857
|
+
printf('\n'.join(self.check_repeat_files(print_mode=False, hash_func=hash_func)))
|
1858
|
+
|
1859
|
+
# 五 错误扩展名
|
1860
|
+
if run_mode & 8:
|
1861
|
+
printf('\n五、错误扩展名')
|
1862
|
+
for i, (f1, suffix2) in enumerate(self.xglob_faker_suffix_files('**/*'), start=1):
|
1863
|
+
printf(f'{i}、{f1.relpath(self)} -> {suffix2}')
|
1864
|
+
|
1865
|
+
# 六 文件配对
|
1866
|
+
if run_mode & 16:
|
1867
|
+
printf(
|
1868
|
+
'\n六、文件配对(检查每个目录里stem名称是否配对,列出文件组成不单一的目录结构,请重点检查落单未配对的情况)')
|
1869
|
+
prompt = False
|
1870
|
+
for root, dirs, files in os.walk(self):
|
1871
|
+
suffix_counts = defaultdict(list)
|
1872
|
+
for file in files:
|
1873
|
+
stem, suffix = os.path.splitext(file)
|
1874
|
+
suffix_counts[stem].append(suffix)
|
1875
|
+
suffix_counts = {k: tuple(sorted(v)) for k, v in suffix_counts.items()}
|
1876
|
+
suffix_counts2 = {v: k for k, v in suffix_counts.items()} # 反向存储,如果有重复v会进行覆盖
|
1877
|
+
ct = Counter(suffix_counts.values())
|
1878
|
+
if len(ct.keys()) > 1:
|
1879
|
+
printf(root)
|
1880
|
+
for k, v in ct.most_common():
|
1881
|
+
tag = f'\t{k}: {v}'
|
1882
|
+
if v == 1:
|
1883
|
+
tag += f',{suffix_counts2[k]}'
|
1884
|
+
if len(k) > 1 and not prompt:
|
1885
|
+
tag += f'\t标记注解:有{v}组stem相同文件,配套有{k}这些后缀。其他标记同理。'
|
1886
|
+
prompt = True
|
1887
|
+
printf(tag)
|
1888
|
+
|
1889
|
+
return '\n'.join(msg)
|
1890
|
+
|
1891
|
+
def __7_目录复合操作(self):
|
1892
|
+
""" 比较高级的一些目录操作功能 """
|
1893
|
+
|
1894
|
+
def delete_empty_subdir(self, recursive=True, topdown=False):
|
1895
|
+
""" 删除指定目录下的所有空目录。
|
1896
|
+
|
1897
|
+
:param recursive: 是否递归删除所有子目录。
|
1898
|
+
:param topdown: 是否从顶部向下遍历目录结构。
|
1899
|
+
默认False,要先删除内部目录,再删除外部目录。
|
1900
|
+
"""
|
1901
|
+
|
1902
|
+
def _delete_empty_dirs(dir_path):
|
1903
|
+
for dir_name in os.listdir(dir_path):
|
1904
|
+
dir_fullpath = os.path.join(dir_path, dir_name)
|
1905
|
+
if os.path.isdir(dir_fullpath):
|
1906
|
+
# 递归删除子目录中的空目录
|
1907
|
+
_delete_empty_dirs(dir_fullpath)
|
1908
|
+
if not os.listdir(dir_fullpath):
|
1909
|
+
# 删除空目录
|
1910
|
+
os.rmdir(dir_fullpath)
|
1911
|
+
|
1912
|
+
if not recursive:
|
1913
|
+
for dirname in os.listdir(self):
|
1914
|
+
dir_fullpath = os.path.join(self, dirname)
|
1915
|
+
if os.path.isdir(dir_fullpath) and not os.listdir(dir_fullpath):
|
1916
|
+
os.rmdir(dir_fullpath)
|
1917
|
+
else:
|
1918
|
+
for dirpath, dirnames, filenames in os.walk(self, topdown=topdown):
|
1919
|
+
for dirname in dirnames:
|
1920
|
+
dir_fullpath = os.path.join(dirpath, dirname)
|
1921
|
+
if not os.listdir(dir_fullpath):
|
1922
|
+
os.rmdir(dir_fullpath)
|
1923
|
+
# 如果不递归删除子目录,则直接跳过
|
1924
|
+
if not recursive:
|
1925
|
+
break
|
1926
|
+
|
1927
|
+
def flatten_directory(self, *, clear_empty_subdir=True):
|
1928
|
+
""" 将子目录的文件全部取出来,放到外面的目录里
|
1929
|
+
|
1930
|
+
:param clear_empty_subdir: 移除文件后,删除空子目录
|
1931
|
+
"""
|
1932
|
+
# 1 检查是否有重名文件,如果有重名文件则终止操作
|
1933
|
+
if msg := self.check_repeat_name_files(print_mode=False):
|
1934
|
+
print('\n'.join(msg))
|
1935
|
+
raise ValueError('有重名文件,终止操作')
|
1936
|
+
|
1937
|
+
# 2 操作
|
1938
|
+
self._flatten_directory_recursive(self, clear_empty_subdir)
|
1939
|
+
|
1940
|
+
def _flatten_directory_recursive(self, current_dir, clear_empty_subdir):
|
1941
|
+
for name in os.listdir(current_dir):
|
1942
|
+
path = os.path.join(current_dir, name)
|
1943
|
+
|
1944
|
+
if os.path.isdir(path):
|
1945
|
+
# If it's a directory, recursively flatten it
|
1946
|
+
self._flatten_directory_recursive(path, clear_empty_subdir)
|
1947
|
+
if clear_empty_subdir:
|
1948
|
+
shutil.rmtree(path)
|
1949
|
+
elif os.path.isfile(path):
|
1950
|
+
# If it's a file, move it to the top-level directory
|
1951
|
+
destination_path = os.path.join(self, name)
|
1952
|
+
shutil.move(path, destination_path)
|
1953
|
+
|
1954
|
+
def _nest_directory_core(self, file_names, min_files_per_batch=None, groupby=None):
|
1955
|
+
""" 核心方法:将文件列表按照指定规则进行分组
|
1956
|
+
|
1957
|
+
:param file_names: 文件列表
|
1958
|
+
:param min_files_per_batch: 每个批次最少包含的文件数,默认为 None,即不限制最少文件数
|
1959
|
+
:param groupby: 分组函数,用于指定按照哪个属性进行分组,默认为 None
|
1960
|
+
:return: 分组结果列表
|
1961
|
+
"""
|
1962
|
+
from pyxllib.algo.pupil import Groups, natural_sort
|
1963
|
+
|
1964
|
+
if groupby is None:
|
1965
|
+
groupby = lambda p: p.stem.lower()
|
1966
|
+
file_groups = Groups.groupby(file_names, groupby).data
|
1967
|
+
|
1968
|
+
if min_files_per_batch is None:
|
1969
|
+
min_files_per_batch = 1
|
1970
|
+
|
1971
|
+
result_groups, current_group = [], []
|
1972
|
+
for stem in natural_sort(file_groups.keys()):
|
1973
|
+
current_group += file_groups[stem]
|
1974
|
+
if len(current_group) >= min_files_per_batch:
|
1975
|
+
result_groups.append(current_group)
|
1976
|
+
current_group = []
|
1977
|
+
if current_group:
|
1978
|
+
result_groups.append(current_group)
|
1979
|
+
|
1980
|
+
return result_groups
|
1981
|
+
|
1982
|
+
def nest_directory(self, min_files_per_batch=None, groupby=None, batch_name=None, bias=0, tail_limit=None):
|
1983
|
+
""" 将直接子文件按照一定规则拆分成多个batch子目录
|
1984
|
+
注意这个功能和flatten_directory是对称的,所以函数名也是对称的
|
1985
|
+
|
1986
|
+
:param min_files_per_batch: 每个batch最少含有的文件数
|
1987
|
+
None,相当于int=1的效果
|
1988
|
+
int, 如果输入一个整数,则按照这个数量约束分成多个batch
|
1989
|
+
:param groupby: 默认会把stem.lower()相同的强制归到一组
|
1990
|
+
def groupby(p: XlPath) -> 分组用的key,相同key会归到同一组
|
1991
|
+
这个不仅用于分组,返回的字符串,也会作为字典序排序的依据,如果想用自然序,记得加natural_sort_key进行转换
|
1992
|
+
:param batch_name: 设置batch的名称,默认 'batch{}'
|
1993
|
+
:param bias: 希望用不到这个参数,只有中途出bug,需要继续处理的时候,用来自动增加编号
|
1994
|
+
注意默认就是从1开始编号的,比如bias设成8的话,实际是从9开始编号的
|
1995
|
+
:param tail_limit: 限制数量少于多少的batch,合并到上一个batch中
|
1996
|
+
"""
|
1997
|
+
from pyxllib.algo.pupil import Groups
|
1998
|
+
|
1999
|
+
# 1 按stem分组,确定分组数
|
2000
|
+
file_names = list(self.glob_files('*'))
|
2001
|
+
if groupby is None:
|
2002
|
+
groupby = lambda p: p.stem.lower()
|
2003
|
+
file_groups = Groups.groupby(file_names, groupby).data
|
2004
|
+
|
2005
|
+
if min_files_per_batch is None:
|
2006
|
+
min_files_per_batch = 1
|
2007
|
+
|
2008
|
+
# 2 将文件组合并为多个分组,每组至少包含 min_files_per_batch 个文件
|
2009
|
+
result_groups, current_group = [], []
|
2010
|
+
for stem in sorted(file_groups.keys()): # 注意这里需要对取到的key按照自然序排序
|
2011
|
+
current_group += file_groups[stem]
|
2012
|
+
if len(current_group) >= min_files_per_batch:
|
2013
|
+
result_groups.append(current_group)
|
2014
|
+
current_group = []
|
2015
|
+
if current_group:
|
2016
|
+
if tail_limit is None:
|
2017
|
+
tail_limit = min_files_per_batch // 10
|
2018
|
+
|
2019
|
+
if len(current_group) < tail_limit and result_groups:
|
2020
|
+
result_groups[-1] += current_group
|
2021
|
+
else:
|
2022
|
+
result_groups.append(current_group)
|
2023
|
+
|
2024
|
+
# 3 整理实际的文件
|
2025
|
+
if batch_name is None:
|
2026
|
+
group_num = len(result_groups)
|
2027
|
+
width = len(str(group_num))
|
2028
|
+
batch_name = f'batch{{:0{width}}}'
|
2029
|
+
for i, group in enumerate(result_groups, start=1):
|
2030
|
+
d = self / batch_name.format(i + bias)
|
2031
|
+
d.mkdir(exist_ok=True)
|
2032
|
+
for f in group:
|
2033
|
+
f.move(d / f.name)
|
2034
|
+
|
2035
|
+
def select_file(self, pos_filter=None, *, neg_filter=None):
|
2036
|
+
from pyxllib.file.specialist.dirlib import Dir
|
2037
|
+
|
2038
|
+
d = Dir(self)
|
2039
|
+
if pos_filter is None:
|
2040
|
+
# 基于filesmatch的底层来实现,速度会比较慢一些,但功能丰富,不用重复造轮子
|
2041
|
+
d = d.select('**/*', type_='file')
|
2042
|
+
else:
|
2043
|
+
d = d.select(pos_filter, type_='file')
|
2044
|
+
if neg_filter is not None:
|
2045
|
+
d = d.exclude(neg_filter)
|
2046
|
+
|
2047
|
+
files = [(self / f) for f in d.subs]
|
2048
|
+
return files
|
2049
|
+
|
2050
|
+
def copy_file_filter(self, dst, pos_filter=None, *, neg_filter=None, if_exists=None):
|
2051
|
+
""" 只能用于目录,在复制文件的时候,进行一定的筛选,而不是完全拷贝
|
2052
|
+
|
2053
|
+
:param dst: 目标目录
|
2054
|
+
:param pos_filter: 对文件的筛选规则
|
2055
|
+
其实常用的就'*.json'这种通配符
|
2056
|
+
支持自定义函数,def pos_filter(p: XlPath)
|
2057
|
+
参数详细用法参考 filesmatch,注意这里筛选器只对file启用,不会对dir启用,否则复制逻辑会非常乱
|
2058
|
+
:param neg_filter:
|
2059
|
+
|
2060
|
+
注意!这个功能还是有点特殊的,不建议和XlPath.copy的接口做合并。
|
2061
|
+
|
2062
|
+
正向、反向两个过滤器可以组合使用,逻辑上是先用正向获取全部文件,然后扣除掉反向。
|
2063
|
+
正向默认全选,反向默认不扣除。
|
2064
|
+
|
2065
|
+
注意:这样过滤后,空目录不会被拷贝。
|
2066
|
+
如果有拷贝目录的需求,请自己另外写逻辑实现。
|
2067
|
+
如果只是拷贝空目录结构的需求,可以使用 copy_dir_structure
|
2068
|
+
|
2069
|
+
>> p = XlPath('build')
|
2070
|
+
>> p.copy_filter('build2', '*.toc') # 复制直接子目录下的所有toc文件
|
2071
|
+
>> p.copy_filter('build2', '**/*.toc') # 复制所有toc文件
|
2072
|
+
>> p.copy_filter('build2', lambda p: p.suffix == '.toc') # 复制所有toc文件
|
2073
|
+
"""
|
2074
|
+
files = self.select_file(pos_filter, neg_filter=neg_filter)
|
2075
|
+
|
2076
|
+
dst = XlPath(dst)
|
2077
|
+
for f in files:
|
2078
|
+
dst2 = dst / f
|
2079
|
+
dst2.parent.mkdir(exist_ok=True)
|
2080
|
+
(self / f).copy(dst2, if_exists=if_exists)
|
2081
|
+
|
2082
|
+
def copy_dir_structure(self, dst):
|
2083
|
+
""" 只复制目录结构,不复制文件内容 """
|
2084
|
+
dst = XlPath(dst)
|
2085
|
+
for root, dirs, _ in os.walk(self):
|
2086
|
+
# 构造目标路径
|
2087
|
+
dst_dir = dst / XlPath(root).relative_to(self)
|
2088
|
+
# 创建目录
|
2089
|
+
dst_dir.mkdir(parents=True, exist_ok=True)
|
2090
|
+
|
2091
|
+
# 无法选定文件
|
2092
|
+
def _move_selectable(self, dst_dir, *, print_mode=False):
|
2093
|
+
""" 目录功能,将目录下可选中的文件移动到目标目录
|
2094
|
+
|
2095
|
+
1、要理解这个看似有点奇怪的功能,需要了解一个背景,在数据处理中,可能会拿到超长文件名的文件,
|
2096
|
+
这种在windows平台虽然手动可以操作,但在代码中,会glob不到,强制指定也会说文件不存在
|
2097
|
+
2、为了解决这类文件问题,一般需要对其进行某种规则的重命名。因为linux里似乎不会限制文件名长度,所以要把这些特殊文件打包到linux里处理。
|
2098
|
+
3、因为这些文件本来就无法被选中,所以只能反向操作,将目录下的可选中文件移动到目标目录。
|
2099
|
+
"""
|
2100
|
+
for p in tqdm(self.glob('*'), disable=not print_mode):
|
2101
|
+
# 231211周一16:03 一般本来就glob不到,现在的p就是存在的,但是可能以防万一加的捕捉,我现在也不敢删
|
2102
|
+
if p.is_file():
|
2103
|
+
try:
|
2104
|
+
p.move(dst_dir / p.name)
|
2105
|
+
except FileNotFoundError:
|
2106
|
+
continue
|
2107
|
+
|
2108
|
+
def move_unselectable(self, dst_dir, *, print_mode=False):
|
2109
|
+
""" 见_move_selectable,因为无法对这些特殊文件进行移动
|
2110
|
+
所以这里只是对_move_selectable的封装,中间通过文件重命名,来伪造移动了无法选中文件的操作效果
|
2111
|
+
"""
|
2112
|
+
tempdir = self.create_tempdir_path(dir=self.parent)
|
2113
|
+
tempdir.mkdir(exist_ok=True)
|
2114
|
+
self._move_selectable(tempdir, print_mode=print_mode)
|
2115
|
+
self.rename2(dst_dir)
|
2116
|
+
tempdir.move(self)
|
2117
|
+
|
2118
|
+
def rename_stem_until_not_exists(self):
|
2119
|
+
""" 比较高级的一个操作,会按照某种规则不断重命名,直到是当前并不存在的文件名,常用在文件拷贝避免重名冲突等场景
|
2120
|
+
|
2121
|
+
todo 写个支持自定义规则的输出参数?
|
2122
|
+
todo 有个首次要不要判断exists的问题,可能跟不同的业务场景有关,要思考怎么设计更好...
|
2123
|
+
"""
|
2124
|
+
|
2125
|
+
def add_stem(m):
|
2126
|
+
a = int(m.group(1)) + 1
|
2127
|
+
return f'({a})'
|
2128
|
+
|
2129
|
+
file = self
|
2130
|
+
while file.exists():
|
2131
|
+
stem = file.stem
|
2132
|
+
m = re.search(r'\(\d+\)$', stem)
|
2133
|
+
if m: # 已经有目标范式的编号,继续累加
|
2134
|
+
stem = re.sub(r'\((\d+)\)$', add_stem, stem)
|
2135
|
+
else: # 还没有编号的,直接从'2'开始编号
|
2136
|
+
stem += ' (2)'
|
2137
|
+
file = self.with_stem(stem.strip()) # 忽略最后空白,这个很容易出问题
|
2138
|
+
|
2139
|
+
return file
|
2140
|
+
|
2141
|
+
|
2142
|
+
class StreamJsonlWriter:
|
2143
|
+
""" 流式存储,主要用于存储文本化、jsonl格式数据 """
|
2144
|
+
|
2145
|
+
def __init__(self, file_path, batch_size=2000, *,
|
2146
|
+
delete_origin_file=False, json_default=str):
|
2147
|
+
self.file_path = XlPath(file_path)
|
2148
|
+
self.cache_text_lines = []
|
2149
|
+
self.batch_size = batch_size
|
2150
|
+
self.total_lines = 0
|
2151
|
+
|
2152
|
+
self.delete_origin_file = delete_origin_file
|
2153
|
+
self.json_default = json_default
|
2154
|
+
|
2155
|
+
def append_line(self, line):
|
2156
|
+
self.append_lines([line])
|
2157
|
+
|
2158
|
+
def append_lines(self, data):
|
2159
|
+
"""
|
2160
|
+
:param list data: 添加一组数据
|
2161
|
+
"""
|
2162
|
+
for x in data:
|
2163
|
+
if isinstance(x, str):
|
2164
|
+
self.cache_text_lines.append(x)
|
2165
|
+
else:
|
2166
|
+
self.cache_text_lines.append(json.dumps(x, ensure_ascii=False,
|
2167
|
+
default=self.json_default))
|
2168
|
+
if len(self.cache_text_lines) >= self.batch_size:
|
2169
|
+
self.flush()
|
2170
|
+
|
2171
|
+
def flush(self):
|
2172
|
+
""" 刷新,将当前缓存写入文件 """
|
2173
|
+
if self.cache_text_lines:
|
2174
|
+
if self.total_lines == 0 and self.delete_origin_file: # 第一次写入时,删除旧缓存文件
|
2175
|
+
self.file_path.delete()
|
2176
|
+
|
2177
|
+
self.total_lines += len(self.cache_text_lines)
|
2178
|
+
self.file_path.parent.mkdir(exist_ok=True, parents=True)
|
2179
|
+
with open(self.file_path, 'a', encoding='utf8') as f:
|
2180
|
+
f.write('\n'.join(self.cache_text_lines) + '\n')
|
2181
|
+
self.cache_text_lines = []
|
2182
|
+
|
2183
|
+
|
2184
|
+
def demo_file():
|
2185
|
+
""" File类的综合测试"""
|
2186
|
+
temp = tempfile.gettempdir()
|
2187
|
+
|
2188
|
+
# 切换工作目录到临时文件夹
|
2189
|
+
os.chdir(temp)
|
2190
|
+
|
2191
|
+
p = File('demo_path', temp)
|
2192
|
+
p.delete() # 如果存在先删除
|
2193
|
+
p.ensure_parent() # 然后再创建一个空目录
|
2194
|
+
|
2195
|
+
print(File('demo_path', suffix='.py'))
|
2196
|
+
# F:\work\CreatorTemp\demo_path.py
|
2197
|
+
|
2198
|
+
print(File('demo_path/', suffix='.py'))
|
2199
|
+
# F:\work\CreatorTemp\demo_path\tmp65m8mc0b.py
|
2200
|
+
|
2201
|
+
# ...区别于None,会随机生成一个文件名
|
2202
|
+
print(File(..., temp))
|
2203
|
+
# F:\work\CreatorTemp\tmpwp4g1692
|
2204
|
+
|
2205
|
+
# 可以在随机名称基础上,再指定文件扩展名
|
2206
|
+
print(File('', temp, suffix='.txt'))
|
2207
|
+
# F:\work\CreatorTemp\tmpimusjtu1.txt
|
2208
|
+
|
2209
|
+
|
2210
|
+
def demo_file_rename():
|
2211
|
+
# 建一个空文件
|
2212
|
+
f1 = File('temp/a.txt', tempfile.gettempdir())
|
2213
|
+
# Path('F:/work/CreatorTemp/temp/a.txt')
|
2214
|
+
with open(str(f1), 'wb') as p: pass # 写一个空文件
|
2215
|
+
|
2216
|
+
f1.rename('A.tXt') # 重命名
|
2217
|
+
# Path('F:/work/CreatorTemp/temp/a.txt')
|
2218
|
+
|
2219
|
+
f1.rename('figs/b') # 放到一个新的子目录里,并再次重命名
|
2220
|
+
# Path('F:/work/CreatorTemp/temp/figs/b')
|
2221
|
+
|
2222
|
+
# TODO 把目录下的1 2 3 4 5重命名为5 4 3 2 1时要怎么搞?
|
2223
|
+
|
2224
|
+
|
2225
|
+
class XlBytesIO(io.BytesIO):
|
2226
|
+
""" 自定义的字节流类,封装了struct_unpack操作
|
2227
|
+
|
2228
|
+
https://www.yuque.com/xlpr/pyxllib/xlbytesio
|
2229
|
+
|
2230
|
+
"""
|
2231
|
+
|
2232
|
+
def __init__(self, init_bytes):
|
2233
|
+
if isinstance(init_bytes, (File, str)):
|
2234
|
+
# with open的作用:可以用.read循序读入,而不是我的Path.read一口气读入。
|
2235
|
+
# 这在只需要进行局部数据分析,f本身又非常大的时候很有用。
|
2236
|
+
# 但是我这里操作不太方便等原因,还是先全部读入BytesIO流了
|
2237
|
+
init_bytes = File(init_bytes).read(mode='b')
|
2238
|
+
super().__init__(init_bytes)
|
2239
|
+
|
2240
|
+
def unpack(self, fmt):
|
2241
|
+
return struct_unpack(self, fmt)
|
2242
|
+
|
2243
|
+
def readtext(self, char_num, encoding='gbk', errors='ignore', code_length=2):
|
2244
|
+
""" 读取二进制流,将其解析为文本内容
|
2245
|
+
|
2246
|
+
:param char_num: 字符数
|
2247
|
+
:param encoding: 所用编码,一般是gbk,因为如果是utf8是字符是变长的,不好操作
|
2248
|
+
:param errors: decode出现错误时的处理方式
|
2249
|
+
:param code_length: 每个字符占的长度
|
2250
|
+
:return: 文本内容
|
2251
|
+
"""
|
2252
|
+
return self.read(code_length * char_num).decode(encoding, errors)
|
2253
|
+
|
2254
|
+
|
2255
|
+
class PathGroups(Groups):
|
2256
|
+
""" 按stem文件名(不含后缀)分组的相关功能 """
|
2257
|
+
|
2258
|
+
@classmethod
|
2259
|
+
def groupby(cls, files, key=lambda x: os.path.splitext(XlPath(x).as_posix())[0], ykey=lambda y: y.suffix[1:]):
|
2260
|
+
"""
|
2261
|
+
:param files: 用Dir.select选中的文件、目录清单
|
2262
|
+
:param key: D:/home/datasets/textGroup/SROIE2019+/data/task3_testcrop/images/X00016469670
|
2263
|
+
:param ykey: ['jpg', 'json', 'txt']
|
2264
|
+
:return: dict
|
2265
|
+
1, task3_testcrop/images/X00016469670:['jpg', 'json', 'txt']
|
2266
|
+
2, task3_testcrop/images/X00016469671:['jpg', 'json', 'txt']
|
2267
|
+
3, task3_testcrop/images/X51005200931:['jpg', 'json', 'txt']
|
2268
|
+
"""
|
2269
|
+
return super().groupby(files, key, ykey)
|
2270
|
+
|
2271
|
+
def select_group(self, judge):
|
2272
|
+
""" 对于某一组,只要该组有满足judge的元素则保留该组 """
|
2273
|
+
data = {k: v for k, v in self.data.items() if judge(k, v)}
|
2274
|
+
return type(self)(data)
|
2275
|
+
|
2276
|
+
def select_group_which_hassuffix(self, pattern, flags=re.IGNORECASE):
|
2277
|
+
def judge(k, values):
|
2278
|
+
for v in values:
|
2279
|
+
m = re.match(pattern, v, flags=flags)
|
2280
|
+
if m and len(m.group()) == len(v):
|
2281
|
+
# 不仅要match满足,还需要整串匹配,比如jpg就必须是jpg,不能是jpga
|
2282
|
+
return True
|
2283
|
+
return False
|
2284
|
+
|
2285
|
+
return self.select_group(judge)
|
2286
|
+
|
2287
|
+
def select_group_which_hasimage(self, pattern=r'jpe?g|png|bmp', flags=re.IGNORECASE):
|
2288
|
+
""" 只保留含有图片格式的分组数据 """
|
2289
|
+
return self.select_group_which_hassuffix(pattern, flags)
|
2290
|
+
|
2291
|
+
def find_files(self, name, *, count=-1):
|
2292
|
+
""" 找指定后缀的文件
|
2293
|
+
|
2294
|
+
:param name: 支持 '1.jpg', 'a/1.jpg' 等格式
|
2295
|
+
:param count: 返回匹配数量上限,-1表示返回所有匹配项
|
2296
|
+
:return: 找到第一个匹配项后返回
|
2297
|
+
找的有就返回XlPath对象
|
2298
|
+
找没有就返回None
|
2299
|
+
|
2300
|
+
注意这个功能是大小写敏感的,如果出现大小写不匹配
|
2301
|
+
要么改文件名本身,要么改name的格式
|
2302
|
+
|
2303
|
+
TODO 如果有大量的检索,这样每次遍历会很慢,可能要考虑构建后缀树来处理
|
2304
|
+
"""
|
2305
|
+
ls = []
|
2306
|
+
stem, ext = os.path.splitext(name)
|
2307
|
+
stem = '/' + stem.replace('\\', '/')
|
2308
|
+
ext = ext[1:]
|
2309
|
+
for k, v in self.data.items():
|
2310
|
+
if k.endswith(stem) and ext in v:
|
2311
|
+
ls.append(XlPath.init(k, suffix=ext))
|
2312
|
+
if len(ls) >= count:
|
2313
|
+
break
|
2314
|
+
return ls
|
2315
|
+
|
2316
|
+
|
2317
|
+
def cache_file(file, make_data_func: Callable[[], Any] = None, *,
|
2318
|
+
mode='read_first',
|
2319
|
+
cache_time=None,
|
2320
|
+
**kwargs):
|
2321
|
+
""" 能将局部函数功能结果缓存进文件的功能
|
2322
|
+
|
2323
|
+
输入的文件file如果存在则直接读取内容;
|
2324
|
+
否则用make_data_func生成,并且备份一个file文件
|
2325
|
+
|
2326
|
+
:param file: 需要缓存的文件路径
|
2327
|
+
:param make_data_func: 如果文件不存在,则需要生成一份,要提供数据生成函数
|
2328
|
+
cache_file可以当装饰器用,此时不用显式指定该参数
|
2329
|
+
:param mode:
|
2330
|
+
read_first(默认): 优先尝试从已有文件读取
|
2331
|
+
generate_first: 函数生成优先
|
2332
|
+
:param cache_time: 文件缓存时间,单位为秒,默认为None,表示始终使用缓存文件
|
2333
|
+
如果设置60,表示超过60秒后,需要重新优先从函数获得更新内容
|
2334
|
+
:param kwargs: 可以传递read、write支持的扩展参数
|
2335
|
+
:return: 读取到的数据
|
2336
|
+
"""
|
2337
|
+
from datetime import datetime, timedelta
|
2338
|
+
from pyxllib.prog.pupil import format_exception
|
2339
|
+
|
2340
|
+
def decorator(func):
|
2341
|
+
def wrapper(*args2, **kwargs2):
|
2342
|
+
|
2343
|
+
f = XlPath.init(file, XlPath.tempdir())
|
2344
|
+
f.parent.mkdir(exist_ok=True, parents=True)
|
2345
|
+
|
2346
|
+
# 1 优先看是不是需要先从文件读取数据
|
2347
|
+
if mode == 'read_first' and f.is_file():
|
2348
|
+
if cache_time is None:
|
2349
|
+
return f.read_auto(**kwargs)
|
2350
|
+
|
2351
|
+
current_time = datetime.now()
|
2352
|
+
last_modified = datetime.fromtimestamp(f.mtime()) # 获取文件的修改时间
|
2353
|
+
if not isinstance(cache_time, timedelta):
|
2354
|
+
cache_time2 = timedelta(seconds=cache_time)
|
2355
|
+
else:
|
2356
|
+
cache_time2 = cache_time
|
2357
|
+
|
2358
|
+
if cache_time is None or (current_time - last_modified <= cache_time2):
|
2359
|
+
return f.read_auto(**kwargs)
|
2360
|
+
|
2361
|
+
# 2 如果需要重新生成数据,且没有已存在的保底文件
|
2362
|
+
if not f.is_file():
|
2363
|
+
data = func(*args2, **kwargs2)
|
2364
|
+
f.write_auto(data, **kwargs)
|
2365
|
+
return data
|
2366
|
+
|
2367
|
+
# 3 需要重新生成,但是有保底文件
|
2368
|
+
try:
|
2369
|
+
data = func(*args2, **kwargs2)
|
2370
|
+
f.write_auto(data, **kwargs)
|
2371
|
+
return data
|
2372
|
+
except Exception as e:
|
2373
|
+
print(format_exception(e))
|
2374
|
+
return f.read_auto(**kwargs)
|
2375
|
+
|
2376
|
+
return wrapper
|
2377
|
+
|
2378
|
+
return decorator(make_data_func)() if make_data_func else decorator
|
2379
|
+
|
2380
|
+
|
2381
|
+
class UsedRecords:
|
2382
|
+
"""存储用户的使用记录到一个文件"""
|
2383
|
+
|
2384
|
+
def __init__(self, filename, default_value=None, *, use_temp_root=False, limit_num=30):
|
2385
|
+
""" 记录存储文件
|
2386
|
+
|
2387
|
+
:param filename: 文件路径与名称
|
2388
|
+
:param default_value:
|
2389
|
+
:param use_temp_root: 使用临时文件夹作为根目录
|
2390
|
+
:param limit_num: 限制条目上限
|
2391
|
+
"""
|
2392
|
+
from os.path import join, dirname, basename, exists
|
2393
|
+
from pyxllib.text.specialist import ensure_content
|
2394
|
+
|
2395
|
+
# 1 文件名处理
|
2396
|
+
if use_temp_root:
|
2397
|
+
dirname = join(os.getenv('TEMP'), 'code4101py_config')
|
2398
|
+
basename = basename(filename)
|
2399
|
+
fullname = join(dirname, basename)
|
2400
|
+
else:
|
2401
|
+
dirname = dirname(filename)
|
2402
|
+
basename = basename(filename)
|
2403
|
+
fullname = filename
|
2404
|
+
|
2405
|
+
# 2 读取值
|
2406
|
+
if exists(fullname):
|
2407
|
+
ls = ensure_content(fullname).splitlines()
|
2408
|
+
else:
|
2409
|
+
ls = list(default_value)
|
2410
|
+
|
2411
|
+
# 3 存储到类
|
2412
|
+
self.dirname = dirname
|
2413
|
+
self.basename = basename
|
2414
|
+
self.fullname = fullname
|
2415
|
+
self.ls = ls
|
2416
|
+
self.limit_num = limit_num
|
2417
|
+
|
2418
|
+
def save(self):
|
2419
|
+
"""保存记录文件"""
|
2420
|
+
File(self.dirname + '/').ensure_parent()
|
2421
|
+
File(self.fullname).write('\n'.join(self.ls), if_exists='replace')
|
2422
|
+
|
2423
|
+
def add(self, s):
|
2424
|
+
"""新增一个使用方法
|
2425
|
+
如果s在self.ls里,则把方法前置到第一条
|
2426
|
+
否则在第一条添加新方法
|
2427
|
+
|
2428
|
+
如果总条数超过30要进行删减
|
2429
|
+
"""
|
2430
|
+
if s in self.ls:
|
2431
|
+
del self.ls[self.ls.index(s)]
|
2432
|
+
|
2433
|
+
self.ls = [s] + list(self.ls)
|
2434
|
+
|
2435
|
+
if len(self.ls) > self.limit_num:
|
2436
|
+
self.ls = self.ls[:self.limit_num]
|
2437
|
+
|
2438
|
+
def __str__(self):
|
2439
|
+
res = list()
|
2440
|
+
res.append(self.fullname)
|
2441
|
+
for t in self.ls:
|
2442
|
+
res.append(t)
|
2443
|
+
return '\n'.join(res)
|
2444
|
+
|
2445
|
+
|
2446
|
+
def __5_filedfs():
|
2447
|
+
"""
|
2448
|
+
对目录的遍历查看目录结构
|
2449
|
+
"""
|
2450
|
+
|
2451
|
+
|
2452
|
+
def file_generator(f):
|
2453
|
+
"""普通文件迭代生成器
|
2454
|
+
:param f: 搜索目录
|
2455
|
+
"""
|
2456
|
+
if os.path.isdir(f):
|
2457
|
+
try:
|
2458
|
+
dirpath, dirnames, filenames = myoswalk(f).__next__()
|
2459
|
+
except StopIteration:
|
2460
|
+
return []
|
2461
|
+
|
2462
|
+
ls = filenames + dirnames
|
2463
|
+
ls = map(lambda x: dirpath + '/' + x, ls)
|
2464
|
+
return ls
|
2465
|
+
else:
|
2466
|
+
return []
|
2467
|
+
|
2468
|
+
|
2469
|
+
def pyfile_generator(f):
|
2470
|
+
"""py文件迭代生成器
|
2471
|
+
:param f: 搜索目录
|
2472
|
+
"""
|
2473
|
+
if os.path.isdir(f):
|
2474
|
+
try:
|
2475
|
+
dirpath, dirnames, filenames = myoswalk(f).__next__()
|
2476
|
+
except StopIteration:
|
2477
|
+
return []
|
2478
|
+
filenames = list(filter(lambda x: x.endswith('.py'), filenames))
|
2479
|
+
ls = filenames + dirnames
|
2480
|
+
ls = map(lambda x: dirpath + '/' + x, ls)
|
2481
|
+
return ls
|
2482
|
+
else:
|
2483
|
+
return []
|
2484
|
+
|
2485
|
+
|
2486
|
+
def texfile_generator(f):
|
2487
|
+
"""tex 文件迭代生成器
|
2488
|
+
:param f: 搜索目录
|
2489
|
+
"""
|
2490
|
+
if os.path.isdir(f):
|
2491
|
+
try:
|
2492
|
+
dirpath, dirnames, filenames = myoswalk(f).__next__()
|
2493
|
+
except StopIteration:
|
2494
|
+
return []
|
2495
|
+
|
2496
|
+
filenames = list(filter(lambda x: x.endswith('.tex'), filenames))
|
2497
|
+
ls = filenames + dirnames
|
2498
|
+
ls = map(lambda x: dirpath + '/' + x, ls)
|
2499
|
+
return ls
|
2500
|
+
else:
|
2501
|
+
return []
|
2502
|
+
|
2503
|
+
|
2504
|
+
def file_str(f):
|
2505
|
+
"""
|
2506
|
+
:param f: 输入完整路径的文件夹或文件名
|
2507
|
+
:return: 返回简化的名称
|
2508
|
+
a/b ==> <b>
|
2509
|
+
a/b.txt ==> b.txt
|
2510
|
+
"""
|
2511
|
+
name = os.path.basename(f)
|
2512
|
+
if os.path.isdir(f):
|
2513
|
+
s = '<' + name + '>'
|
2514
|
+
else:
|
2515
|
+
s = name
|
2516
|
+
return s
|
2517
|
+
|
2518
|
+
|
2519
|
+
def filedfs(root,
|
2520
|
+
child_generator=file_generator, select_depth=None, linenum=True,
|
2521
|
+
mystr=file_str, msghead=True, lsstr=None, show_node_type=False, prefix='\t'):
|
2522
|
+
"""对文件结构的递归遍历
|
2523
|
+
注意这里的子节点生成器有对非常多特殊情况进行过滤,并不是通用的文件夹查看工具
|
2524
|
+
"""
|
2525
|
+
from pyxllib.text.specialist import dfs_base
|
2526
|
+
|
2527
|
+
if isinstance(child_generator, str):
|
2528
|
+
if child_generator == '.py':
|
2529
|
+
child_generator = pyfile_generator
|
2530
|
+
elif child_generator == '.tex':
|
2531
|
+
child_generator = texfile_generator
|
2532
|
+
else:
|
2533
|
+
raise ValueError
|
2534
|
+
|
2535
|
+
return dfs_base(root, child_generator=child_generator, select_depth=select_depth, linenum=linenum,
|
2536
|
+
mystr=mystr, msghead=msghead, lsstr=lsstr, show_node_type=show_node_type, prefix=prefix)
|
2537
|
+
|
2538
|
+
|
2539
|
+
def genfilename(fd='.'):
|
2540
|
+
"""生成一个fd目录下的文件名
|
2541
|
+
注意只是文件名,并未实际产生文件,输入目录是为了防止生成重名文件(以basename为标准的无重名)
|
2542
|
+
|
2543
|
+
格式为:180827周一195802,如果出现重名,前面的6位记为数值d1,是年份+月份+日期的标签
|
2544
|
+
后面的6位记为数值d2,类似小时+分钟+秒的标签,但是在出现重名时,
|
2545
|
+
d2会一直自加1直到没有重名文件,所以秒上是可能会出现“99”之类的值的。
|
2546
|
+
"""
|
2547
|
+
from datetime import datetime
|
2548
|
+
# 1 获取前段标签
|
2549
|
+
dt = datetime.now()
|
2550
|
+
weektag = '一二三四五六日'
|
2551
|
+
s1 = dt.strftime('%y%m%d') + f'周{weektag[dt.weekday()]}' # '180827周一'
|
2552
|
+
|
2553
|
+
# 2 获取后端数值标签
|
2554
|
+
d2 = int(datetime.now().strftime('%H%M%S'))
|
2555
|
+
|
2556
|
+
# 3 获取目录下文件,并迭代确保生成一个不重名文件
|
2557
|
+
ls = os.listdir(fd)
|
2558
|
+
files = set(map(lambda x: os.path.basename(os.path.splitext(x)[0]), ls)) # 收集basename
|
2559
|
+
|
2560
|
+
while s1 + str(d2) in files:
|
2561
|
+
d2 += 1
|
2562
|
+
|
2563
|
+
return s1 + str(d2)
|
2564
|
+
|
2565
|
+
|
2566
|
+
def myoswalk(root, filter_rule=None, recur=True):
|
2567
|
+
"""
|
2568
|
+
:param root: 根目录
|
2569
|
+
:param filter_rule:
|
2570
|
+
字符串
|
2571
|
+
以点.开头的,统一认为是进行后缀格式识别
|
2572
|
+
其他字符串类型会认为是一个正则规则,只要相对root的全名能search到规则即认为匹配
|
2573
|
+
可以将中文问号用于匹配任意汉字
|
2574
|
+
也可以输入自定义函数: 输入参数是相对root目录下的文件全名
|
2575
|
+
:param recur: 是否进行子文件夹递归
|
2576
|
+
:return:
|
2577
|
+
"""
|
2578
|
+
if isinstance(filter_rule, str):
|
2579
|
+
filter_rule = gen_file_filter(filter_rule)
|
2580
|
+
|
2581
|
+
# prefix_len = len(root) # 计算出前缀长度
|
2582
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
2583
|
+
# relative_root = dirpath[prefix_len+1:] # 我想返回相对路径,但是好像不太规范会对很多东西造成麻烦
|
2584
|
+
# 过滤掉特殊目录
|
2585
|
+
for t in ('.git', '$RECYCLE.BIN', '__pycache__', 'temp', 'Old', 'old'):
|
2586
|
+
try:
|
2587
|
+
del dirnames[dirnames.index(t)]
|
2588
|
+
except ValueError:
|
2589
|
+
pass
|
2590
|
+
# 去掉备份文件
|
2591
|
+
dirnames = list(filter(lambda x: not File(x).backup_time and '-冲突-' not in x, dirnames))
|
2592
|
+
filenames = list(filter(lambda x: not File(x).backup_time and '-冲突-' not in x, filenames))
|
2593
|
+
|
2594
|
+
# 调用特殊过滤规则
|
2595
|
+
if filter_rule:
|
2596
|
+
dirnames = list(filter(lambda x: filter_rule(f'{dirpath}\\{x}'), dirnames))
|
2597
|
+
filenames = list(filter(lambda x: filter_rule(f'{dirpath}\\{x}'), filenames))
|
2598
|
+
|
2599
|
+
# 如果该文件夹下已经没有文件,不返回该目录
|
2600
|
+
if not (filenames or dirnames):
|
2601
|
+
continue
|
2602
|
+
|
2603
|
+
# 返回生成结果
|
2604
|
+
yield dirpath, dirnames, filenames
|
2605
|
+
|
2606
|
+
if not recur: # 不进行递归
|
2607
|
+
break
|
2608
|
+
|
2609
|
+
|
2610
|
+
def mygetfiles(root, filter_rule=None, recur=True):
|
2611
|
+
r""" 对myoswalk进一步封装,返回所有匹配的文件
|
2612
|
+
会递归查找所有子文件
|
2613
|
+
|
2614
|
+
可以这样遍历一个目录下的所有文件:
|
2615
|
+
for f in mygetfiles(r'C:\pycode\code4101py', r'.py'):
|
2616
|
+
print(f)
|
2617
|
+
这个函数已经自动过滤掉备份文件了
|
2618
|
+
筛选规则除了“.+后缀”,还可以写正则匹配
|
2619
|
+
|
2620
|
+
参数含义详见myoswalk
|
2621
|
+
"""
|
2622
|
+
for root, _, files in myoswalk(root, filter_rule, recur):
|
2623
|
+
for f in files:
|
2624
|
+
yield root + '\\' + f
|
2625
|
+
|
2626
|
+
|
2627
|
+
def __6_high():
|
2628
|
+
""" 一些高级的路径功能 """
|
2629
|
+
|
2630
|
+
|
2631
|
+
class DirsFileFinder:
|
2632
|
+
""" 多目录里的文件检索类 """
|
2633
|
+
|
2634
|
+
def __init__(self, *dirs):
|
2635
|
+
""" 支持按优先级输入多个目录dirs,会对这些目录里的文件进行统一检索 """
|
2636
|
+
self.names = defaultdict(list)
|
2637
|
+
self.stems = defaultdict(list)
|
2638
|
+
|
2639
|
+
for d in dirs:
|
2640
|
+
self.add_dir(d)
|
2641
|
+
|
2642
|
+
def add_dir(self, p, cvt_name_func=None):
|
2643
|
+
""" 添加备用检索目录
|
2644
|
+
当前面的目录找不到匹配项的时候,会使用备用目录的文件
|
2645
|
+
备用目录可以一直添加,有多个,优先级逐渐降低
|
2646
|
+
|
2647
|
+
:param cvt_name_func: 对名称做个转换再匹配
|
2648
|
+
"""
|
2649
|
+
files = list(XlPath(p).rglob_files())
|
2650
|
+
if cvt_name_func:
|
2651
|
+
for f in files:
|
2652
|
+
self.names[cvt_name_func(f.name)].append(f)
|
2653
|
+
self.stems[cvt_name_func(f.stem)].append(f)
|
2654
|
+
else:
|
2655
|
+
for f in files:
|
2656
|
+
self.names[f.name].append(f)
|
2657
|
+
self.stems[f.stem].append(f)
|
2658
|
+
|
2659
|
+
def find_name(self, name):
|
2660
|
+
""" 返回第一个匹配的结果 """
|
2661
|
+
files = self.find_names(name)
|
2662
|
+
if files:
|
2663
|
+
return files[0]
|
2664
|
+
|
2665
|
+
def find_names(self, name):
|
2666
|
+
""" 返回所有匹配的结果 """
|
2667
|
+
return self.names[name]
|
2668
|
+
|
2669
|
+
def find_stem(self, stem):
|
2670
|
+
files = self.find_stems(stem)
|
2671
|
+
if files:
|
2672
|
+
return files[0]
|
2673
|
+
|
2674
|
+
def find_stems(self, stem):
|
2675
|
+
return self.stems[stem]
|
2676
|
+
|
2677
|
+
def find_prefix_name(self, prefix_name, suffix=None):
|
2678
|
+
files = self.find_prefix_names(prefix_name, suffix=suffix)
|
2679
|
+
if files:
|
2680
|
+
return files[0]
|
2681
|
+
|
2682
|
+
def find_prefix_names(self, prefix_name, suffix=None):
|
2683
|
+
""" name中前缀为prefix_name """
|
2684
|
+
filess = [files for name, files in self.names.items() if name.startswith(prefix_name)]
|
2685
|
+
# 将嵌套的list展平
|
2686
|
+
files = [file for files in filess for file in files]
|
2687
|
+
if suffix:
|
2688
|
+
files = [file for file in files if file.suffix == suffix]
|
2689
|
+
return files
|
2690
|
+
|
2691
|
+
|
2692
|
+
class TwinDirs:
|
2693
|
+
def __init__(self, src_dir, dst_dir):
|
2694
|
+
""" 一对'孪生'目录,一般是有一个src_dir,还有个同结构的dst_dir。
|
2695
|
+
但dst_dir往往并不存在,是准备从src_dir处理过来的。
|
2696
|
+
"""
|
2697
|
+
self.src_dir = XlPath(src_dir)
|
2698
|
+
self.dst_dir = XlPath(dst_dir)
|
2699
|
+
self.src_dir_finder = None
|
2700
|
+
|
2701
|
+
def reset_dst_dir(self):
|
2702
|
+
""" 重置目标目录 """
|
2703
|
+
self.dst_dir.delete()
|
2704
|
+
self.dst_dir.mkdir()
|
2705
|
+
|
2706
|
+
def copy_file(self, src_file, if_exists=None):
|
2707
|
+
""" 从src复制一个文件到dst里
|
2708
|
+
|
2709
|
+
:param XlPath src_file: 原文件位置(其实输入目录类型也是可以的,内部实现逻辑一致的)
|
2710
|
+
"""
|
2711
|
+
src_file = XlPath(src_file)
|
2712
|
+
_src_file = src_file.relpath(self.src_dir)
|
2713
|
+
dst_file = self.dst_dir / _src_file
|
2714
|
+
dst_dir = dst_file.parent
|
2715
|
+
dst_dir.mkdir(exist_ok=True, parents=True) # 确保目录结构存在
|
2716
|
+
src_file.copy(dst_file, if_exists=if_exists)
|
2717
|
+
return dst_file
|
2718
|
+
|
2719
|
+
def copy_ext_file(self, ext_file, if_exists=None):
|
2720
|
+
""" 和copy_file区别,这里输入的ext_file是来自其他目录的文件
|
2721
|
+
但是要在src_file里找到位置,按照结构复制到dst_dir
|
2722
|
+
"""
|
2723
|
+
# 1 找到原始文件位置
|
2724
|
+
ext_file = XlPath(ext_file)
|
2725
|
+
if self.src_dir_finder is None:
|
2726
|
+
self.src_dir_finder = DirsFileFinder(self.src_dir)
|
2727
|
+
|
2728
|
+
src_files = self.src_dir_finder.find_names(ext_file.name)
|
2729
|
+
assert len(src_files) < 2, '出现多种匹配可能性,请检查目录'
|
2730
|
+
|
2731
|
+
if len(src_files) == 0:
|
2732
|
+
src_files = self.src_dir_finder.find_stems(ext_file.stem)
|
2733
|
+
assert len(src_files), '没有找到可匹配的文件'
|
2734
|
+
|
2735
|
+
src_file = src_files[0]
|
2736
|
+
|
2737
|
+
# 2 复制文件
|
2738
|
+
_src_file = src_file.relpath(self.src_dir)
|
2739
|
+
dst_file = self.dst_dir / _src_file
|
2740
|
+
dst_dir = dst_file.parent
|
2741
|
+
dst_dir.mkdir(exist_ok=True, parents=True) # 确保目录结构存在
|
2742
|
+
ext_file.copy(dst_file, if_exists=if_exists)
|
2743
|
+
print(ext_file, '->', dst_file)
|
2744
|
+
return dst_file
|
2745
|
+
|
2746
|
+
def copy_dir_structure(self):
|
2747
|
+
""" 复制目录结构 """
|
2748
|
+
self.src_dir.copy_dir_structure(self.dst_dir)
|
2749
|
+
|
2750
|
+
|
2751
|
+
class BatchFileRenamer:
|
2752
|
+
""" 对一批数据,按照某种规则判重、重命名去重
|
2753
|
+
一般是对stem重命名后,确保数据随意混合后名称也不会有出现重复
|
2754
|
+
"""
|
2755
|
+
|
2756
|
+
def __init__(self, _dir=None):
|
2757
|
+
"""
|
2758
|
+
:param _dir: 输入待处理的第一个目录
|
2759
|
+
"""
|
2760
|
+
# 所有待处理的文件
|
2761
|
+
self.files = []
|
2762
|
+
if _dir is not None:
|
2763
|
+
self.add_dir(_dir)
|
2764
|
+
|
2765
|
+
def add_dir(self, _dir):
|
2766
|
+
""" 添加一个目录下的所有文件
|
2767
|
+
如果有比较零散的文件待处理,可以直接操作self.files
|
2768
|
+
|
2769
|
+
todo 目录的重命名?如果引入目录的重命名,算法会复杂非常多的,这个暂不考虑。
|
2770
|
+
"""
|
2771
|
+
for f in XlPath(_dir).rglob_files():
|
2772
|
+
self.files.append(f)
|
2773
|
+
|
2774
|
+
def get_key(self, file):
|
2775
|
+
""" 计算一个文件的重复标识,不同文件之间的判重依据 """
|
2776
|
+
# 默认的key,规则会比较严,stem不重复,大小写不重复
|
2777
|
+
return file.stem.lower()
|
2778
|
+
|
2779
|
+
def get_new_name(self, file, exists_keys=None):
|
2780
|
+
""" 输入的f必须是已经确定要进行重命名的文件
|
2781
|
+
|
2782
|
+
对于windows来说
|
2783
|
+
是先假定在一个组中,增加编号
|
2784
|
+
如果编号的文件已经存在,则换一个新的命名组
|
2785
|
+
|
2786
|
+
对我这里来说,就不搞这么复杂了,就是无脑加编号就行
|
2787
|
+
"""
|
2788
|
+
|
2789
|
+
def add_stem(m):
|
2790
|
+
a = int(m.group(1)) + 1
|
2791
|
+
return f'({a})'
|
2792
|
+
|
2793
|
+
stem = file.stem
|
2794
|
+
while True:
|
2795
|
+
m = re.search(r'\(\d+\)$', stem)
|
2796
|
+
if m: # 已经有目标范式的编号,继续累加
|
2797
|
+
stem = re.sub(r'\((\d+)\)$', add_stem, stem)
|
2798
|
+
else: # 还没有编号的,直接从'2'开始编号
|
2799
|
+
stem += ' (2)'
|
2800
|
+
|
2801
|
+
f2 = file.with_stem(stem.strip()) # 忽略最后空白,这个很容易出问题
|
2802
|
+
k2 = self.get_key(f2)
|
2803
|
+
if k2 not in exists_keys: # 如果新的命名不会跟旧有文件有任何重复,循环就可以终止了
|
2804
|
+
exists_keys.add(k2)
|
2805
|
+
return f2
|
2806
|
+
|
2807
|
+
def rename_files(self, print_mode=False, exists_keys=None):
|
2808
|
+
""" 对文件进行批量重命名
|
2809
|
+
|
2810
|
+
这里分两轮运行,是有非常深的用意的,避免前面重命名的时候,没有见到后面可能会出现的重名文件
|
2811
|
+
"""
|
2812
|
+
# 1 先遍历一遍文件,确认哪些文件是确定要重命名的
|
2813
|
+
exists_keys = exists_keys or set()
|
2814
|
+
repeat_name_files = [] # 确认要进行重命名的文件
|
2815
|
+
for f in self.files:
|
2816
|
+
k = self.get_key(f)
|
2817
|
+
if k not in exists_keys:
|
2818
|
+
exists_keys.add(k)
|
2819
|
+
else:
|
2820
|
+
repeat_name_files.append(f)
|
2821
|
+
|
2822
|
+
# 2 对需要重命名的文件进行操作
|
2823
|
+
cnt = 0
|
2824
|
+
for f in repeat_name_files:
|
2825
|
+
cnt += 1
|
2826
|
+
f2 = self.get_new_name(f, exists_keys)
|
2827
|
+
if print_mode:
|
2828
|
+
print(cnt, f.as_posix(), '-->', f2.name)
|
2829
|
+
f.rename2(f2)
|