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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyutilscripts
3
- Version: 0.1.0b1
3
+ Version: 0.3.0b0
4
4
  Summary: PyUtilScripts 是一个基于 Python 的通用小工具集合,目标是提供编写通用任务的辅助工具。
5
5
  Author-email: Zero Kwok <zero.kwok@foxmail.com>
6
6
  License: MIT License
@@ -2,8 +2,8 @@
2
2
  PyUtilScripts 是一个基于 Python 的通用小工具集合,目标是提供编写通用任务的辅助工具。
3
3
  """
4
4
 
5
- __version__ = "0.1.0"
6
- __status__ = "beta1"
5
+ __version__ = "0.3.0"
6
+ __status__ = "beta0"
7
7
  __author__ = "zero <zero.kwok@foxmail.com>"
8
8
 
9
9
  projectName = 'PyUtilScripts'
@@ -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))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyutilscripts
3
- Version: 0.1.0b1
3
+ Version: 0.3.0b0
4
4
  Summary: PyUtilScripts 是一个基于 Python 的通用小工具集合,目标是提供编写通用任务的辅助工具。
5
5
  Author-email: Zero Kwok <zero.kwok@foxmail.com>
6
6
  License: MIT License
@@ -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