pyutilscripts 0.1.0b1__tar.gz → 0.3.0b0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pyutilscripts-0.1.0b1 → pyutilscripts-0.3.0b0}/PKG-INFO +1 -1
- {pyutilscripts-0.1.0b1 → pyutilscripts-0.3.0b0}/pyutilscripts/__init__.py +2 -2
- pyutilscripts-0.3.0b0/pyutilscripts/fcopy.py +467 -0
- pyutilscripts-0.3.0b0/pyutilscripts/utils/__init__.py +17 -0
- {pyutilscripts-0.1.0b1 → pyutilscripts-0.3.0b0}/pyutilscripts.egg-info/PKG-INFO +1 -1
- {pyutilscripts-0.1.0b1 → pyutilscripts-0.3.0b0}/pyutilscripts.egg-info/SOURCES.txt +4 -1
- pyutilscripts-0.3.0b0/tests/test_fcopy.py +107 -0
- pyutilscripts-0.3.0b0/tests/test_fcopy_cli.py +67 -0
- pyutilscripts-0.1.0b1/pyutilscripts/fcopy.py +0 -116
- {pyutilscripts-0.1.0b1 → pyutilscripts-0.3.0b0}/LICENSE +0 -0
- {pyutilscripts-0.1.0b1 → pyutilscripts-0.3.0b0}/README.md +0 -0
- {pyutilscripts-0.1.0b1 → pyutilscripts-0.3.0b0}/pyproject.toml +0 -0
- {pyutilscripts-0.1.0b1 → pyutilscripts-0.3.0b0}/pyutilscripts/forward_tcp.py +0 -0
- {pyutilscripts-0.1.0b1 → pyutilscripts-0.3.0b0}/pyutilscripts/prunedirs.py +0 -0
- {pyutilscripts-0.1.0b1 → pyutilscripts-0.3.0b0}/pyutilscripts.egg-info/dependency_links.txt +0 -0
- {pyutilscripts-0.1.0b1 → pyutilscripts-0.3.0b0}/pyutilscripts.egg-info/entry_points.txt +0 -0
- {pyutilscripts-0.1.0b1 → pyutilscripts-0.3.0b0}/pyutilscripts.egg-info/requires.txt +0 -0
- {pyutilscripts-0.1.0b1 → pyutilscripts-0.3.0b0}/pyutilscripts.egg-info/top_level.txt +0 -0
- {pyutilscripts-0.1.0b1 → pyutilscripts-0.3.0b0}/setup.cfg +0 -0
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
#! python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
#
|
|
4
|
+
# This file is part of the PyUtilScripts project.
|
|
5
|
+
# Copyright (c) 2020-2025 zero <zero.kwok@foxmail.com>
|
|
6
|
+
#
|
|
7
|
+
# For the full copyright and license information, please view the LICENSE
|
|
8
|
+
# file that was distributed with this source code.
|
|
9
|
+
#
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
import shlex
|
|
15
|
+
import click
|
|
16
|
+
import shutil
|
|
17
|
+
import difflib
|
|
18
|
+
import filecmp
|
|
19
|
+
import argparse
|
|
20
|
+
import datetime
|
|
21
|
+
import traceback
|
|
22
|
+
from typing import Tuple, List
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from natsort import natsorted
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
from termcolor import cprint
|
|
27
|
+
|
|
28
|
+
from . import utils
|
|
29
|
+
|
|
30
|
+
CopyModes = ["update", "overwrite", "rename", "u", "o", "r"]
|
|
31
|
+
|
|
32
|
+
ListFileHeader = """# File list generated by fcopy on {}
|
|
33
|
+
# One file per line, relative to source directory
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
ActionsFileHeader = """# Action plan for file copying (edit this file to change actions)
|
|
37
|
+
# Actions:
|
|
38
|
+
#
|
|
39
|
+
# c - Copy, when target doesn't exist
|
|
40
|
+
# u - Update, when target exists with mismatched metadata but source is newer
|
|
41
|
+
# o - Overwrite, when target exists, unconditionally copy and overwrite (--mode overwrite)
|
|
42
|
+
# r - Rename, when target exists, copy with incremented filename (--mode rename)
|
|
43
|
+
# i - Ignore, when target exists with mismatched metadata but deep comparison shows no difference
|
|
44
|
+
# s - Skip, when target exists and no differences found in shallow or deep comparison
|
|
45
|
+
#
|
|
46
|
+
# Example:
|
|
47
|
+
# c file1.txt Copy
|
|
48
|
+
# r file3.txt -> file(3).txt Copy and Rename to file(3).txt
|
|
49
|
+
# o file2.txt Overwrite
|
|
50
|
+
# s file2.txt Skipped because the files are the same
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def read_file_list(filename, comment='#', keep_comments=False):
|
|
54
|
+
"""Read the list of files to copy from the manifest file."""
|
|
55
|
+
if filename is None:
|
|
56
|
+
return None
|
|
57
|
+
with open(filename, 'r') as f:
|
|
58
|
+
lines = [line.strip() for line in f.readlines()]
|
|
59
|
+
|
|
60
|
+
# Filter out comments and empty lines
|
|
61
|
+
files = []
|
|
62
|
+
for line in lines:
|
|
63
|
+
if not keep_comments:
|
|
64
|
+
if line.startswith(comment) or not line:
|
|
65
|
+
continue
|
|
66
|
+
files.append(line)
|
|
67
|
+
return files
|
|
68
|
+
|
|
69
|
+
def filter_match(file, patterns):
|
|
70
|
+
if not patterns:
|
|
71
|
+
return False
|
|
72
|
+
return any(p.match(file) for p in patterns)
|
|
73
|
+
|
|
74
|
+
def read_file_filter(filename, comment='#'):
|
|
75
|
+
patterns = []
|
|
76
|
+
try:
|
|
77
|
+
for line in read_file_list(filename, comment, False) or []:
|
|
78
|
+
patterns.append(re.compile(line))
|
|
79
|
+
except FileNotFoundError:
|
|
80
|
+
pass # ignore missing file
|
|
81
|
+
return patterns
|
|
82
|
+
|
|
83
|
+
def make_file_list(source, filters=[], verbose=False):
|
|
84
|
+
files = []
|
|
85
|
+
for root, _, names in os.walk(source):
|
|
86
|
+
for n in names:
|
|
87
|
+
filename = os.path.join(root, n)
|
|
88
|
+
filename = os.path.relpath(filename, start=source)
|
|
89
|
+
if filter_match(filename, filters):
|
|
90
|
+
if verbose:
|
|
91
|
+
cprint(f"Filtered: {filename}", "yellow")
|
|
92
|
+
continue
|
|
93
|
+
files.append(filename)
|
|
94
|
+
|
|
95
|
+
date = datetime.now().isoformat(timespec='seconds')
|
|
96
|
+
head = ListFileHeader.format(date).splitlines()
|
|
97
|
+
return head + natsorted(files)
|
|
98
|
+
|
|
99
|
+
def update_file_list(args):
|
|
100
|
+
"""Update the file list with the current contents of the source directory."""
|
|
101
|
+
new = make_file_list(args.source, args.filter_patterns, args.verbose)
|
|
102
|
+
old = []
|
|
103
|
+
if os.path.exists(args.list):
|
|
104
|
+
with open(args.list, 'r') as f:
|
|
105
|
+
old = [line.strip() for line in f.readlines()]
|
|
106
|
+
|
|
107
|
+
# 生成 unified diff
|
|
108
|
+
diff = difflib.unified_diff(
|
|
109
|
+
old,
|
|
110
|
+
new,
|
|
111
|
+
fromfile=args.list + ' (old)',
|
|
112
|
+
tofile=args.list + ' (new)',
|
|
113
|
+
lineterm=''
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# 对 diff 行进行彩色渲染
|
|
117
|
+
print('')
|
|
118
|
+
for line in diff:
|
|
119
|
+
if line.startswith('+'):
|
|
120
|
+
cprint(line, 'green') # 新增行:绿色
|
|
121
|
+
elif line.startswith('-'):
|
|
122
|
+
cprint(line, 'red') # 删除行:红色
|
|
123
|
+
elif line.startswith('@@'):
|
|
124
|
+
cprint(line, 'cyan') # 差异位置标记:青色
|
|
125
|
+
else:
|
|
126
|
+
cprint(line)
|
|
127
|
+
|
|
128
|
+
# Ask for confirmation
|
|
129
|
+
cprint(f"\nUpdate {args.list} with these changes? [y/N]", end=" ")
|
|
130
|
+
confirm = input().strip().lower()
|
|
131
|
+
if confirm != 'y':
|
|
132
|
+
cprint('User Cancelled', "red", file=sys.stderr)
|
|
133
|
+
return 1
|
|
134
|
+
|
|
135
|
+
with open(args.list, 'w') as f:
|
|
136
|
+
f.write('\n'.join(new))
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
def file_cmp(file1, file2, stat1, stat2) -> Tuple[bool, int]:
|
|
140
|
+
"""Compare two files and return a tuple of (is_same, meta_cmp)."""
|
|
141
|
+
# 浅比较, 比较文件大小和修改时间
|
|
142
|
+
if stat1.st_size == stat2.st_size and stat1.st_mtime == stat2.st_mtime:
|
|
143
|
+
return True, 0
|
|
144
|
+
|
|
145
|
+
# 根据修改时间, 对比谁比较新
|
|
146
|
+
meta_cmp = 1 if stat1.st_mtime >= stat2.st_mtime else -1
|
|
147
|
+
|
|
148
|
+
# 深比较
|
|
149
|
+
def _do_cmp(f1, f2):
|
|
150
|
+
bufsize = 8*1024
|
|
151
|
+
with open(f1, 'rb') as fp1, open(f2, 'rb') as fp2:
|
|
152
|
+
while True:
|
|
153
|
+
b1 = fp1.read(bufsize)
|
|
154
|
+
b2 = fp2.read(bufsize)
|
|
155
|
+
if b1 != b2:
|
|
156
|
+
return False
|
|
157
|
+
if not b1:
|
|
158
|
+
return True
|
|
159
|
+
if stat1.st_size == stat2.st_size and _do_cmp(file1, file2):
|
|
160
|
+
return True, meta_cmp
|
|
161
|
+
return False, meta_cmp
|
|
162
|
+
|
|
163
|
+
def increment_filename(directory, filename, rename_list):
|
|
164
|
+
"""
|
|
165
|
+
在指定目录中为给定文件名生成不冲突的新文件名(仅文件名部分,不含路径)。
|
|
166
|
+
支持多扩展名(如 .tar.gz)及已有的 (1)、(2) 递增模式。
|
|
167
|
+
"""
|
|
168
|
+
directory = Path(directory)
|
|
169
|
+
filename = Path(filename)
|
|
170
|
+
components = filename.parent
|
|
171
|
+
filename = filename.name
|
|
172
|
+
|
|
173
|
+
stem, *suffixes = filename.split('.')
|
|
174
|
+
suffix = '.' + '.'.join(suffixes) if suffixes else ''
|
|
175
|
+
|
|
176
|
+
# 若 stem 形如 "file(1)",则提取基础名与编号
|
|
177
|
+
match = re.match(r'^(.*?)(\((\d+)\))?$', stem )
|
|
178
|
+
if match:
|
|
179
|
+
stem = match.group(1)
|
|
180
|
+
number = int(match.group(3)) if match.group(3) else 0
|
|
181
|
+
else:
|
|
182
|
+
number = 0
|
|
183
|
+
|
|
184
|
+
# 除了判断文件系统中是否存在之外, 还要判断计划中产生的文件是否存在
|
|
185
|
+
def exists(d, c, f):
|
|
186
|
+
if (d / c / f).exists():
|
|
187
|
+
return True
|
|
188
|
+
if rename_list:
|
|
189
|
+
if (c / f) in rename_list:
|
|
190
|
+
return True
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
# 初始候选
|
|
194
|
+
candidate = filename
|
|
195
|
+
while exists(directory, components, candidate):
|
|
196
|
+
number += 1
|
|
197
|
+
candidate = f"{stem}({number}){suffix}"
|
|
198
|
+
|
|
199
|
+
return components / candidate
|
|
200
|
+
|
|
201
|
+
class Action:
|
|
202
|
+
"""Action类用于描述操作行为"""
|
|
203
|
+
def __init__(self, action: str, src: str, dst: str = '', common: str = None):
|
|
204
|
+
self.action = action
|
|
205
|
+
self.src = src
|
|
206
|
+
self.dst = dst
|
|
207
|
+
self.common = common
|
|
208
|
+
|
|
209
|
+
def __iter__(self):
|
|
210
|
+
return iter((self.action, self.src, self.dst))
|
|
211
|
+
|
|
212
|
+
def natsorted(actions):
|
|
213
|
+
priority = {c: i for i,c in enumerate(['c', 'u', 'o', 'r', 'i', 's'])}
|
|
214
|
+
return natsorted(actions, key=lambda a: (priority[a.action], a.src, a.dst))
|
|
215
|
+
|
|
216
|
+
def make_actions(args):
|
|
217
|
+
items: List[Action] = []
|
|
218
|
+
rename_list: List[str] = []
|
|
219
|
+
|
|
220
|
+
for file in args.manifest:
|
|
221
|
+
if filter_match(file, args.filter_patterns):
|
|
222
|
+
if args.verbose:
|
|
223
|
+
cprint(f"Filtered: {file}", "yellow")
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
source = os.path.normpath(os.path.join(args.source, file))
|
|
227
|
+
target = os.path.normpath(os.path.join(args.target, file))
|
|
228
|
+
try:
|
|
229
|
+
stat1 = os.stat(source)
|
|
230
|
+
except:
|
|
231
|
+
# 源文件不存在则发出警告, 不视为错误
|
|
232
|
+
cprint(f"Warn: SourceFileNotFound -> {source}", "magenta", file=sys.stderr)
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
stat2 = os.stat(target)
|
|
237
|
+
except FileNotFoundError:
|
|
238
|
+
items.append(Action('c', file))
|
|
239
|
+
continue
|
|
240
|
+
|
|
241
|
+
if args.mode in ('r', 'rename'):
|
|
242
|
+
file2 = increment_filename(args.target, file, rename_list)
|
|
243
|
+
items.append(Action('c', file, str(file2)))
|
|
244
|
+
rename_list.append(file2)
|
|
245
|
+
continue
|
|
246
|
+
|
|
247
|
+
elif args.mode in ('o', 'overwrite'):
|
|
248
|
+
items.append(Action('o', file))
|
|
249
|
+
continue
|
|
250
|
+
|
|
251
|
+
# update mode
|
|
252
|
+
is_same, meta_cmp = file_cmp(source, target, stat1, stat2)
|
|
253
|
+
common = lambda: (
|
|
254
|
+
[
|
|
255
|
+
f"src: {utils.format_ftime(stat1.st_mtime)}, {utils.format_bytes(stat1.st_size)}",
|
|
256
|
+
f"dst: {utils.format_ftime(stat2.st_mtime)}, {utils.format_bytes(stat2.st_size)}"
|
|
257
|
+
]
|
|
258
|
+
if meta_cmp != 0
|
|
259
|
+
else None
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
if is_same:
|
|
263
|
+
items.append(Action('s' if meta_cmp == 0 else 'i', file, common=common()))
|
|
264
|
+
else:
|
|
265
|
+
items.append(Action('u' if meta_cmp >= 1 else 'i', file, common=common()))
|
|
266
|
+
|
|
267
|
+
return Action.natsorted(items)
|
|
268
|
+
|
|
269
|
+
def parse_actions(lines, comment='#'):
|
|
270
|
+
files = []
|
|
271
|
+
for row, line in enumerate(lines):
|
|
272
|
+
line = line.strip()
|
|
273
|
+
if not line or line.startswith(comment):
|
|
274
|
+
continue
|
|
275
|
+
|
|
276
|
+
# Handle action-prefixed lines (for edit mode)
|
|
277
|
+
if ' ' not in line:
|
|
278
|
+
raise ValueError(f"Invalid line: {row}: {line}")
|
|
279
|
+
|
|
280
|
+
if '->'in line:
|
|
281
|
+
fields = shlex.split(line, posix=os.name != 'nt')
|
|
282
|
+
else:
|
|
283
|
+
fields = line.split(maxsplit=1)
|
|
284
|
+
|
|
285
|
+
if len(fields) == 2:
|
|
286
|
+
action, file1, file2 = fields + ['']
|
|
287
|
+
elif len(fields) == 4 and '->' in fields:
|
|
288
|
+
action, file1, _, file2 = fields
|
|
289
|
+
else:
|
|
290
|
+
raise ValueError(f"Invalid line: {row}: {line}, parse as: {fields}")
|
|
291
|
+
files.append(Action(action, file1.strip(' \'"'), file2.strip(' \'"')))
|
|
292
|
+
|
|
293
|
+
return files
|
|
294
|
+
|
|
295
|
+
def read_file_actions(filename, comment='#'):
|
|
296
|
+
return parse_actions(read_file_list(filename, comment, True), comment)
|
|
297
|
+
|
|
298
|
+
def join_actions(actions:list[Action], header:str, verbose:int):
|
|
299
|
+
lines = []
|
|
300
|
+
for item in actions:
|
|
301
|
+
line = f'{item.action} "{item.src}"'
|
|
302
|
+
if item.dst:
|
|
303
|
+
line += f' -> "{item.dst}"'
|
|
304
|
+
lines.append(line)
|
|
305
|
+
|
|
306
|
+
for c in item.common or []:
|
|
307
|
+
lines.append(f' # {c}')
|
|
308
|
+
|
|
309
|
+
return header.rstrip() + "\n\n" + "\n".join(lines) + "\n"
|
|
310
|
+
|
|
311
|
+
def print_actions(actions:list, header:str, verbose:int):
|
|
312
|
+
print()
|
|
313
|
+
cprint(f"The following actions will be performed:", "yellow")
|
|
314
|
+
lines = join_actions(actions, header, verbose)
|
|
315
|
+
for line in lines.splitlines():
|
|
316
|
+
if not line:
|
|
317
|
+
print()
|
|
318
|
+
continue
|
|
319
|
+
|
|
320
|
+
a = line[0]
|
|
321
|
+
c = {"#": "dark_grey", "s": "yellow", "o": "green", "c": "green", " ": "white"}
|
|
322
|
+
f = a if a in c else " "
|
|
323
|
+
cprint(line, c[f])
|
|
324
|
+
print()
|
|
325
|
+
|
|
326
|
+
def get_available_editor(defaults=("micro", "nano", "vim", "vi", "notepad")):
|
|
327
|
+
"""检查哪个编辑器可用,返回第一个可用的,否则返回 None"""
|
|
328
|
+
if "EDITOR" in os.environ:
|
|
329
|
+
defaults.insert(0, os.environ["EDITOR"])
|
|
330
|
+
cprint(f'Preferred editor detected: {defaults[0]}', 'yellow')
|
|
331
|
+
for editor in defaults:
|
|
332
|
+
if shutil.which(editor): # 检查是否在 PATH 里
|
|
333
|
+
return editor
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
def edit_actions(actions:list, header:str, verbose:int) -> list:
|
|
337
|
+
"""
|
|
338
|
+
使用 click.edit() 启动编辑器让用户编辑行动计划。
|
|
339
|
+
返回: None 用户取消编辑或没保存
|
|
340
|
+
"""
|
|
341
|
+
content = join_actions(actions, header, verbose)
|
|
342
|
+
|
|
343
|
+
# 打开编辑器让用户编辑内容
|
|
344
|
+
edited = click.edit(content, extension=".actions-todo", editor=get_available_editor())
|
|
345
|
+
if edited is None:
|
|
346
|
+
cprint("User canceled or didn't save, aborted.", "red", file=sys.stderr)
|
|
347
|
+
raise SystemExit()
|
|
348
|
+
|
|
349
|
+
# 解析用户编辑后的结果
|
|
350
|
+
return parse_actions(edited.splitlines(), '#')
|
|
351
|
+
|
|
352
|
+
def copy_files(args):
|
|
353
|
+
"""Copy files from source directory to target directory with specified manifest"""
|
|
354
|
+
args.manifest = read_file_list(args.list)
|
|
355
|
+
if not args.manifest:
|
|
356
|
+
cprint('Error: list file is empty or invalid.', "red", file=sys.stderr)
|
|
357
|
+
return 1
|
|
358
|
+
|
|
359
|
+
actions = make_actions(args)
|
|
360
|
+
if not actions:
|
|
361
|
+
cprint("Error: No actions to perform.", "red", file=sys.stderr)
|
|
362
|
+
return 1
|
|
363
|
+
|
|
364
|
+
if args.interactive:
|
|
365
|
+
actions = edit_actions(actions, ActionsFileHeader, args.verbose)
|
|
366
|
+
elif args.dry_run or args.verbose > 1:
|
|
367
|
+
print_actions(actions, ActionsFileHeader, args.verbose)
|
|
368
|
+
|
|
369
|
+
copied, skipped = 0, 0
|
|
370
|
+
for action, file1, file2 in actions:
|
|
371
|
+
file2 = file2 or file1
|
|
372
|
+
if action == 's':
|
|
373
|
+
skipped += 1
|
|
374
|
+
continue
|
|
375
|
+
|
|
376
|
+
if action in ('c', 'u', 'o', 'r'):
|
|
377
|
+
source = os.path.normpath(os.path.join(args.source, file1))
|
|
378
|
+
target = os.path.normpath(os.path.join(args.target, file2))
|
|
379
|
+
if os.path.isdir(source):
|
|
380
|
+
continue
|
|
381
|
+
|
|
382
|
+
prefix = {'c': 'Copying', 'o': 'Replacing', 'u': 'Updating', 'r': 'Renaming'}[action]
|
|
383
|
+
if args.dry_run:
|
|
384
|
+
cprint(f"Dry run: {prefix}: {file1} -> {file2}", "cyan")
|
|
385
|
+
elif args.verbose > 0:
|
|
386
|
+
cprint(f"{prefix} {file1} -> {file2}", 'green')
|
|
387
|
+
else:
|
|
388
|
+
cprint(f"{prefix} {file1}", 'green')
|
|
389
|
+
|
|
390
|
+
if not args.dry_run:
|
|
391
|
+
try:
|
|
392
|
+
os.makedirs(os.path.dirname(target), exist_ok=True)
|
|
393
|
+
shutil.copy2(source, target)
|
|
394
|
+
except OSError as e:
|
|
395
|
+
cprint(f"Error copying {source} to {target}: {e}", "red", file=sys.stderr)
|
|
396
|
+
copied += 1
|
|
397
|
+
|
|
398
|
+
cprint(f"Done. {copied} files copied, {skipped} skipped.")
|
|
399
|
+
return 0
|
|
400
|
+
|
|
401
|
+
def main():
|
|
402
|
+
try:
|
|
403
|
+
parser = argparse.ArgumentParser(
|
|
404
|
+
description="Copy files from source directory to target directory with flexible copy modes.",
|
|
405
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
|
406
|
+
parser = argparse.ArgumentParser(description='Copy files from source directory to target directory.')
|
|
407
|
+
parser.add_argument('-l', '--list', default='fcopy.list', help='File containing the list of files to copy.')
|
|
408
|
+
parser.add_argument("-s", "--source", required=True, help="source directory containing files to copy")
|
|
409
|
+
parser.add_argument("-t", "--target", help="target directory where files will be copied")
|
|
410
|
+
parser.add_argument("-m", "--mode", default="update", choices=CopyModes, help="copy mode: u|update, o|overwrite, r|rename")
|
|
411
|
+
parser.add_argument("-i", "--interactive", action="store_true", help="Let the user edit the list of action plans to copy")
|
|
412
|
+
parser.add_argument("-v", "--verbose", action="count", default=0, help="increase verbosity level (use -vv for more detail)")
|
|
413
|
+
parser.add_argument('--filter', default='fcopy.filter', help='file containing blacklist regex patterns, one per line.')
|
|
414
|
+
parser.add_argument("--update-list", action="store_true", help="update the --list file with current --source contents (with confirmation)")
|
|
415
|
+
parser.add_argument("--dry-run", action="store_true", help="simulate operations without actually copying files")
|
|
416
|
+
parser.add_argument('--debug', action='store_true', default=False, help=argparse.SUPPRESS)
|
|
417
|
+
|
|
418
|
+
try:
|
|
419
|
+
args = parser.parse_args()
|
|
420
|
+
except SystemExit:
|
|
421
|
+
print('\n'.join(parser.format_help().splitlines()[1:]))
|
|
422
|
+
raise
|
|
423
|
+
|
|
424
|
+
# argparse 默认会保留字符串中的引号
|
|
425
|
+
for key in args.__dict__:
|
|
426
|
+
if type(args.__dict__[key]) == str:
|
|
427
|
+
args.__dict__[key] = args.__dict__[key].strip(' \'"')
|
|
428
|
+
|
|
429
|
+
if args.debug:
|
|
430
|
+
args.verbose = 2
|
|
431
|
+
input('Wait for debugging and press Enter to continue...')
|
|
432
|
+
|
|
433
|
+
if not args.list or not args.source:
|
|
434
|
+
cprint("Error: Please provide the required arguments.", "red", file=sys.stderr)
|
|
435
|
+
parser.print_help()
|
|
436
|
+
return 1
|
|
437
|
+
|
|
438
|
+
args.mode = args.mode.lower()
|
|
439
|
+
args.source = os.path.normpath(os.path.abspath(args.source))
|
|
440
|
+
if not os.path.isdir(args.source):
|
|
441
|
+
cprint(f"Error: Source directory '{args.source}' does not exist", "red", file=sys.stderr)
|
|
442
|
+
return 1
|
|
443
|
+
|
|
444
|
+
# Read the filter file
|
|
445
|
+
args.filter_patterns = read_file_filter(args.filter)
|
|
446
|
+
if not args.filter_patterns and "_specified" in vars(args) and "filter" in args._specified:
|
|
447
|
+
cprint(f"Warning: No valid patterns found in filter file '{args.filter}'.", "yellow")
|
|
448
|
+
|
|
449
|
+
# Check if running in the terminal, because editor is only available in terminal
|
|
450
|
+
if args.interactive and not sys.stdout.isatty():
|
|
451
|
+
cprint("Warning: Not running in the terminal (may be a redirect or pipe)", "yellow")
|
|
452
|
+
|
|
453
|
+
if args.update_list:
|
|
454
|
+
return update_file_list(args)
|
|
455
|
+
else:
|
|
456
|
+
if args.target is None:
|
|
457
|
+
cprint(f"Error: Please provide the target directory.")
|
|
458
|
+
return 1
|
|
459
|
+
args.target = os.path.normpath(os.path.abspath(args.target))
|
|
460
|
+
return copy_files(args)
|
|
461
|
+
|
|
462
|
+
except KeyboardInterrupt:
|
|
463
|
+
cprint('\nKeyboard Interrupt', 'red', end='')
|
|
464
|
+
return 1
|
|
465
|
+
|
|
466
|
+
if __name__ == "__main__":
|
|
467
|
+
sys.exit(main())
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def format_bytes(size_bytes: int, precision: int = 2) -> str:
|
|
6
|
+
"""自动将字节数格式化为人类可读的单位(B, KB, MB, GB...)"""
|
|
7
|
+
if size_bytes == 0:
|
|
8
|
+
return "0B"
|
|
9
|
+
units = ("B", "KB", "MB", "GB", "TB", "PB")
|
|
10
|
+
i = int(math.floor(math.log(size_bytes, 1024)))
|
|
11
|
+
size = size_bytes / (1024**i)
|
|
12
|
+
return f"{size:.{precision}f}{units[i]}"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def format_ftime(seconds: float, format: str = "%y-%m-%d %H:%M"):
|
|
16
|
+
"""将秒数格式化为日期和时间"""
|
|
17
|
+
return time.strftime(format, time.localtime(seconds))
|
|
@@ -10,4 +10,7 @@ pyutilscripts.egg-info/SOURCES.txt
|
|
|
10
10
|
pyutilscripts.egg-info/dependency_links.txt
|
|
11
11
|
pyutilscripts.egg-info/entry_points.txt
|
|
12
12
|
pyutilscripts.egg-info/requires.txt
|
|
13
|
-
pyutilscripts.egg-info/top_level.txt
|
|
13
|
+
pyutilscripts.egg-info/top_level.txt
|
|
14
|
+
pyutilscripts/utils/__init__.py
|
|
15
|
+
tests/test_fcopy.py
|
|
16
|
+
tests/test_fcopy_cli.py
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import sys
|
|
4
|
+
import types
|
|
5
|
+
import pytest
|
|
6
|
+
import filecmp
|
|
7
|
+
import tempfile
|
|
8
|
+
from unittest import mock
|
|
9
|
+
from pyutilscripts import fcopy
|
|
10
|
+
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
def file_manifest(monkeypatch):
|
|
13
|
+
manifest = tempfile.mktemp()
|
|
14
|
+
monkeypatch.setattr(sys, "argv", ["fcopy.py", "-s", ".", "-l", manifest, "--update-list"])
|
|
15
|
+
monkeypatch.setattr("builtins.input", lambda args=None: "y")
|
|
16
|
+
code = fcopy.main()
|
|
17
|
+
assert code == 0
|
|
18
|
+
return manifest
|
|
19
|
+
|
|
20
|
+
def dircmp(dir1, dir2):
|
|
21
|
+
result = filecmp.dircmp(dir1, dir2)
|
|
22
|
+
if not (
|
|
23
|
+
len(result.left_only) == 0
|
|
24
|
+
and len(result.right_only) == 0
|
|
25
|
+
and len(result.diff_files) == 0 ):
|
|
26
|
+
return False
|
|
27
|
+
for dir in result.common_dirs:
|
|
28
|
+
if not dircmp(os.path.join(dir1, dir), os.path.join(dir2, dir)):
|
|
29
|
+
return False
|
|
30
|
+
return True
|
|
31
|
+
|
|
32
|
+
def test_update_list(monkeypatch, file_manifest):
|
|
33
|
+
stat = os.stat(file_manifest)
|
|
34
|
+
assert stat.st_size > 0
|
|
35
|
+
|
|
36
|
+
def test_copy_files_with_update_and_rename(monkeypatch, file_manifest):
|
|
37
|
+
target = tempfile.mktemp()
|
|
38
|
+
monkeypatch.setattr(sys, "argv", ["fcopy.py", "-s", ".", "-l", file_manifest, "-t", target])
|
|
39
|
+
code = fcopy.main()
|
|
40
|
+
assert code == 0
|
|
41
|
+
|
|
42
|
+
assert os.path.isdir(target)
|
|
43
|
+
result = dircmp('.', target)
|
|
44
|
+
assert result
|
|
45
|
+
|
|
46
|
+
# rename mode
|
|
47
|
+
monkeypatch.setattr(sys, "argv", ["fcopy.py", "-s", ".", "-l", file_manifest, "-t", target, "-m", "r"])
|
|
48
|
+
code = fcopy.main()
|
|
49
|
+
assert code == 0
|
|
50
|
+
|
|
51
|
+
# Compare file counts: target should have twice as many files as the source directory
|
|
52
|
+
def count_files(directory):
|
|
53
|
+
count = 0
|
|
54
|
+
for root, dirs, files in os.walk(directory):
|
|
55
|
+
count += len(files)
|
|
56
|
+
return count
|
|
57
|
+
|
|
58
|
+
source_count = count_files('.')
|
|
59
|
+
target_count = count_files(target)
|
|
60
|
+
assert target_count == 2 * source_count
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_update_list_with_filter(monkeypatch, file_manifest):
|
|
64
|
+
manifest = tempfile.mktemp()
|
|
65
|
+
monkeypatch.setattr(sys, "argv", ["fcopy.py", "-s", ".", "-l", manifest, "--update-list", "--filter", "filter.txt"])
|
|
66
|
+
|
|
67
|
+
# Patch update_file_list
|
|
68
|
+
called = {}
|
|
69
|
+
def fake_read_file_filter(args):
|
|
70
|
+
called['ok'] = True
|
|
71
|
+
return [re.compile(line) for line in ['^file.txt$', '^.+__pycache__.+$', '^\.git.+$']]
|
|
72
|
+
monkeypatch.setattr("pyutilscripts.fcopy.read_file_filter", fake_read_file_filter)
|
|
73
|
+
code = fcopy.main()
|
|
74
|
+
assert code == 0
|
|
75
|
+
assert called.get('ok')
|
|
76
|
+
|
|
77
|
+
left = fcopy.read_file_list(file_manifest)
|
|
78
|
+
right = fcopy.read_file_list(manifest)
|
|
79
|
+
diff = set(left) - set(right)
|
|
80
|
+
assert diff
|
|
81
|
+
assert 'file.txt' in diff
|
|
82
|
+
assert len(diff) > 2
|
|
83
|
+
|
|
84
|
+
def test_copy_files_with_filter(monkeypatch, file_manifest):
|
|
85
|
+
target = tempfile.mktemp()
|
|
86
|
+
monkeypatch.setattr(sys, "argv", ["fcopy.py", "-s", ".", "-l", file_manifest, "-t", target, "--filter", "filter.txt"])
|
|
87
|
+
|
|
88
|
+
# Patch update_file_list
|
|
89
|
+
called = {}
|
|
90
|
+
def fake_read_file_filter(args):
|
|
91
|
+
called['ok'] = True
|
|
92
|
+
return [re.compile(line) for line in ['^file.txt$', '^.+__pycache__.+$', '^\.git.+$']]
|
|
93
|
+
monkeypatch.setattr("pyutilscripts.fcopy.read_file_filter", fake_read_file_filter)
|
|
94
|
+
|
|
95
|
+
code = fcopy.main()
|
|
96
|
+
assert code == 0
|
|
97
|
+
|
|
98
|
+
# Compare file counts: target should have twice as many files as the source directory
|
|
99
|
+
def count_files(directory):
|
|
100
|
+
count = 0
|
|
101
|
+
for root, dirs, files in os.walk(directory):
|
|
102
|
+
count += len(files)
|
|
103
|
+
return count
|
|
104
|
+
|
|
105
|
+
source_count = count_files('.')
|
|
106
|
+
target_count = count_files(target)
|
|
107
|
+
assert source_count - target_count > 2
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import types
|
|
3
|
+
import pytest
|
|
4
|
+
from unittest import mock
|
|
5
|
+
from pyutilscripts import fcopy
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture
|
|
9
|
+
def patch_common(monkeypatch):
|
|
10
|
+
# Patch cprint to avoid terminal output
|
|
11
|
+
monkeypatch.setattr("pyutilscripts.fcopy.cprint", lambda *a, **kw: None)
|
|
12
|
+
# Patch print to avoid clutter
|
|
13
|
+
monkeypatch.setattr("builtins.print", lambda *a, **kw: None)
|
|
14
|
+
|
|
15
|
+
def test_cli_missing_required_args(monkeypatch, patch_common):
|
|
16
|
+
monkeypatch.setattr(sys, "argv", ["fcopy.py"])
|
|
17
|
+
with pytest.raises(SystemExit):
|
|
18
|
+
fcopy.main()
|
|
19
|
+
|
|
20
|
+
def test_cli_source_dir_not_exist(monkeypatch, patch_common):
|
|
21
|
+
monkeypatch.setattr(sys, "argv", ["fcopy.py", "-s", "notfound", "-t", "target"])
|
|
22
|
+
monkeypatch.setattr("os.path.isdir", lambda p: False)
|
|
23
|
+
code = fcopy.main() # Should print error and return 1
|
|
24
|
+
assert code == 1
|
|
25
|
+
|
|
26
|
+
def test_cli_update_list(monkeypatch, patch_common):
|
|
27
|
+
monkeypatch.setattr(sys, "argv", ["fcopy.py", "-s", "src", "--update-list"])
|
|
28
|
+
monkeypatch.setattr("os.path.isdir", lambda p: True)
|
|
29
|
+
# Patch update_file_list to check it's called
|
|
30
|
+
called = {}
|
|
31
|
+
def fake_update(args):
|
|
32
|
+
called['ok'] = True
|
|
33
|
+
return 0
|
|
34
|
+
monkeypatch.setattr("pyutilscripts.fcopy.update_file_list", fake_update)
|
|
35
|
+
code = fcopy.main()
|
|
36
|
+
assert called.get('ok')
|
|
37
|
+
assert code == 0
|
|
38
|
+
|
|
39
|
+
def test_cli_copy_files_dry_run(monkeypatch, patch_common):
|
|
40
|
+
monkeypatch.setattr(sys, "argv", ["fcopy.py", "-s", "src", "-t", "target", "--dry-run"])
|
|
41
|
+
monkeypatch.setattr("os.path.isdir", lambda p: True)
|
|
42
|
+
# Patch read_file_list to return a manifest
|
|
43
|
+
monkeypatch.setattr("pyutilscripts.fcopy.read_file_list", lambda f,c,k: ["file1.txt"])
|
|
44
|
+
# Patch make_actions to return actions
|
|
45
|
+
monkeypatch.setattr("pyutilscripts.fcopy.make_actions", lambda args: [fcopy.Action("c", "file1.txt", "")])
|
|
46
|
+
# Patch copy_files to check it's called
|
|
47
|
+
called = {}
|
|
48
|
+
def fake_copy(args):
|
|
49
|
+
called['ok'] = True
|
|
50
|
+
return 0
|
|
51
|
+
monkeypatch.setattr("pyutilscripts.fcopy.copy_files", fake_copy)
|
|
52
|
+
code = fcopy.main()
|
|
53
|
+
assert called.get('ok')
|
|
54
|
+
assert code == 0
|
|
55
|
+
|
|
56
|
+
def test_cli_target_missing(monkeypatch, patch_common):
|
|
57
|
+
monkeypatch.setattr(sys, "argv", ["fcopy.py", "-s", "src"])
|
|
58
|
+
monkeypatch.setattr("os.path.isdir", lambda p: True)
|
|
59
|
+
code = fcopy.main() # Should print error and return
|
|
60
|
+
assert code == 1
|
|
61
|
+
|
|
62
|
+
def test_cli_debug_mode(monkeypatch, patch_common):
|
|
63
|
+
monkeypatch.setattr(sys, "argv", ["fcopy.py", "-s", "src", "-t", "target", "--debug"])
|
|
64
|
+
monkeypatch.setattr("os.path.isdir", lambda p: True)
|
|
65
|
+
monkeypatch.setattr("builtins.input", lambda *a, **kw: "")
|
|
66
|
+
monkeypatch.setattr("pyutilscripts.fcopy.copy_files", lambda args: None)
|
|
67
|
+
fcopy.main()
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
#! python
|
|
2
|
-
# -*- coding: utf-8 -*-
|
|
3
|
-
#
|
|
4
|
-
# This file is part of the PyUtilScripts project.
|
|
5
|
-
# Copyright (c) 2020-2025 zero <zero.kwok@foxmail.com>
|
|
6
|
-
#
|
|
7
|
-
# For the full copyright and license information, please view the LICENSE
|
|
8
|
-
# file that was distributed with this source code.
|
|
9
|
-
#
|
|
10
|
-
# ###
|
|
11
|
-
#
|
|
12
|
-
# 1. 以tree -aif命令输出的内容作为文件清单(如下).
|
|
13
|
-
# 2. 通过脚本将(directory)原目录下的匹配文件, 拷贝到(output)目标目录.
|
|
14
|
-
#
|
|
15
|
-
# 语法如下
|
|
16
|
-
# fcopy.py [-l,--list FILE] [-d,--directory DIRECTORY] <-o,--output DIRECTORY>
|
|
17
|
-
#
|
|
18
|
-
# CMD
|
|
19
|
-
# python fcopy.py -l ../fcopy.list -d . -o "\\192.168.1.230\2024-01-26 1625"
|
|
20
|
-
|
|
21
|
-
import os
|
|
22
|
-
import shlex
|
|
23
|
-
import sys
|
|
24
|
-
import stat
|
|
25
|
-
import shutil
|
|
26
|
-
import filecmp
|
|
27
|
-
import argparse
|
|
28
|
-
import traceback
|
|
29
|
-
from termcolor import cprint
|
|
30
|
-
|
|
31
|
-
def copy_files(source_directory, target_directory, manifest, report:dict):
|
|
32
|
-
for file in manifest:
|
|
33
|
-
source = os.path.normpath(os.path.join(source_directory, file))
|
|
34
|
-
target = os.path.normpath(os.path.join(target_directory, file))
|
|
35
|
-
|
|
36
|
-
try:
|
|
37
|
-
fs = os.stat(source)
|
|
38
|
-
if fs.st_mode & stat.S_IFDIR:
|
|
39
|
-
continue
|
|
40
|
-
except FileNotFoundError:
|
|
41
|
-
cprint(f'Failed: SourceFileNotFound -> {source}', 'red', file=sys.stderr)
|
|
42
|
-
report[0].setdefault('SourceFileNotFound', []).append(source)
|
|
43
|
-
continue
|
|
44
|
-
|
|
45
|
-
if os.path.exists(target):
|
|
46
|
-
if filecmp.cmp(source, target):
|
|
47
|
-
print(f'Skip: SameFile -> {source} -> {target}')
|
|
48
|
-
report[1].setdefault('SkipSameFile', []).append(source)
|
|
49
|
-
continue
|
|
50
|
-
else:
|
|
51
|
-
print(f'Update: {source} -> {target}')
|
|
52
|
-
report[1].setdefault('Update', []).append(source)
|
|
53
|
-
else:
|
|
54
|
-
print(f'Copy: {source} -> {target}')
|
|
55
|
-
report[1].setdefault('Copy', []).append(source)
|
|
56
|
-
os.makedirs(os.path.dirname(target), exist_ok=True)
|
|
57
|
-
shutil.copy2(source, target)
|
|
58
|
-
|
|
59
|
-
def read_file_list(file_path):
|
|
60
|
-
with open(file_path, 'r') as file:
|
|
61
|
-
return [line.strip() for line in file.readlines() if not line[0].isdigit()]
|
|
62
|
-
|
|
63
|
-
def main(sequence = None):
|
|
64
|
-
try:
|
|
65
|
-
parser = argparse.ArgumentParser(description='Copy files from source directory to target directory.')
|
|
66
|
-
parser.add_argument('-l', '--list', default='fcopy.list', help='File containing the list of files to copy.')
|
|
67
|
-
parser.add_argument('-d', '--directory', default='.', help='Source directory where the files are located.')
|
|
68
|
-
parser.add_argument('-o', '--output', required=True, help='Target directory where the files will be copied.')
|
|
69
|
-
parser.add_argument('-v', '--verbose', action='store_true', default=False)
|
|
70
|
-
|
|
71
|
-
while True:
|
|
72
|
-
try:
|
|
73
|
-
args = parser.parse_args(shlex.split(sequence, posix=False) if sequence else None)
|
|
74
|
-
break
|
|
75
|
-
except SystemExit:
|
|
76
|
-
sequence = input('$ Please enter parameters:\n')
|
|
77
|
-
continue
|
|
78
|
-
|
|
79
|
-
for key in args.__dict__:
|
|
80
|
-
if type(args.__dict__[key]) == str:
|
|
81
|
-
args.__dict__[key] = args.__dict__[key].strip(' \'"')
|
|
82
|
-
|
|
83
|
-
if not args.list or not args.directory or not args.output:
|
|
84
|
-
print("Error: Please provide the required arguments.")
|
|
85
|
-
parser.print_help()
|
|
86
|
-
return
|
|
87
|
-
|
|
88
|
-
report = { 0 : {}, 1 : {} }
|
|
89
|
-
manifest = read_file_list(args.list)
|
|
90
|
-
copy_files(args.directory, args.output, manifest, report)
|
|
91
|
-
|
|
92
|
-
failed = {}
|
|
93
|
-
success = {}
|
|
94
|
-
for key in report[0]:
|
|
95
|
-
failed[key] = failed.setdefault(key, 0) + len(report[0][key])
|
|
96
|
-
for key in report[1]:
|
|
97
|
-
success[key] = success.setdefault(key, 0) + len(report[1][key])
|
|
98
|
-
failed = [f'{i}: {failed[i]}' for i in failed]
|
|
99
|
-
success = [f'{i}: {success[i]}' for i in success]
|
|
100
|
-
|
|
101
|
-
print()
|
|
102
|
-
if args.verbose:
|
|
103
|
-
print()
|
|
104
|
-
for key in report[0]:
|
|
105
|
-
for f in report[0][key]:
|
|
106
|
-
cprint(f'{key}: {f}', 'red')
|
|
107
|
-
if 'Update' in report[1]:
|
|
108
|
-
for f in report[1]['Update']:
|
|
109
|
-
cprint(f'{key}: {f}', 'green')
|
|
110
|
-
cprint(f'{", ".join(success)} {", ".join(failed)}', 'yellow' if failed else 'green')
|
|
111
|
-
except:
|
|
112
|
-
traceback.print_exc()
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if __name__ == "__main__":
|
|
116
|
-
main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|