pyutilscripts 0.3.0b0__tar.gz → 0.5.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. pyutilscripts-0.5.1/PKG-INFO +82 -0
  2. pyutilscripts-0.5.1/README.md +52 -0
  3. {pyutilscripts-0.3.0b0 → pyutilscripts-0.5.1}/pyproject.toml +9 -1
  4. {pyutilscripts-0.3.0b0 → pyutilscripts-0.5.1}/pyutilscripts/__init__.py +2 -2
  5. {pyutilscripts-0.3.0b0 → pyutilscripts-0.5.1}/pyutilscripts/fcopy.py +203 -68
  6. pyutilscripts-0.5.1/pyutilscripts.egg-info/PKG-INFO +82 -0
  7. {pyutilscripts-0.3.0b0 → pyutilscripts-0.5.1}/pyutilscripts.egg-info/SOURCES.txt +1 -0
  8. pyutilscripts-0.5.1/pyutilscripts.egg-info/requires.txt +7 -0
  9. pyutilscripts-0.5.1/tests/test_action_parser.py +63 -0
  10. {pyutilscripts-0.3.0b0 → pyutilscripts-0.5.1}/tests/test_fcopy.py +4 -3
  11. pyutilscripts-0.3.0b0/PKG-INFO +0 -55
  12. pyutilscripts-0.3.0b0/README.md +0 -30
  13. pyutilscripts-0.3.0b0/pyutilscripts.egg-info/PKG-INFO +0 -55
  14. pyutilscripts-0.3.0b0/pyutilscripts.egg-info/requires.txt +0 -1
  15. {pyutilscripts-0.3.0b0 → pyutilscripts-0.5.1}/LICENSE +0 -0
  16. {pyutilscripts-0.3.0b0 → pyutilscripts-0.5.1}/pyutilscripts/forward_tcp.py +0 -0
  17. {pyutilscripts-0.3.0b0 → pyutilscripts-0.5.1}/pyutilscripts/prunedirs.py +0 -0
  18. {pyutilscripts-0.3.0b0 → pyutilscripts-0.5.1}/pyutilscripts/utils/__init__.py +0 -0
  19. {pyutilscripts-0.3.0b0 → pyutilscripts-0.5.1}/pyutilscripts.egg-info/dependency_links.txt +0 -0
  20. {pyutilscripts-0.3.0b0 → pyutilscripts-0.5.1}/pyutilscripts.egg-info/entry_points.txt +0 -0
  21. {pyutilscripts-0.3.0b0 → pyutilscripts-0.5.1}/pyutilscripts.egg-info/top_level.txt +0 -0
  22. {pyutilscripts-0.3.0b0 → pyutilscripts-0.5.1}/setup.cfg +0 -0
  23. {pyutilscripts-0.3.0b0 → pyutilscripts-0.5.1}/tests/test_fcopy_cli.py +0 -0
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyutilscripts
3
+ Version: 0.5.1
4
+ Summary: PyUtilScripts 是一个基于 Python 的通用小工具集合,目标是提供编写通用任务的辅助工具。
5
+ Author-email: Zero Kwok <zero.kwok@foxmail.com>
6
+ License: MIT License
7
+ Project-URL: Homepage, https://github.com/ZeroKwok/pyutilscripts
8
+ Project-URL: Issues, https://github.com/ZeroKwok/pyutilscripts/issues
9
+ Keywords: tools
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Topic :: Software Development :: Build Tools
12
+ Classifier: Programming Language :: Python
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.7
16
+ Classifier: Programming Language :: Python :: 3.8
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Requires-Python: >=3.7
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: click
24
+ Requires-Dist: natsort
25
+ Requires-Dist: termcolor
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest; extra == "dev"
28
+ Requires-Dist: pytest-cov; extra == "dev"
29
+ Dynamic: license-file
30
+
31
+ # **PyUtilScripts**
32
+
33
+ `PyUtilScripts` 是一个基于 Python 的通用小工具集合,目标是提供编写通用任务的辅助工具。
34
+
35
+ ## 📦 安装
36
+
37
+ ### 通过 pip 安装
38
+
39
+ ```bash
40
+ pip install pyutilscripts
41
+ ```
42
+
43
+ ### 从源码安装
44
+
45
+ ```bash
46
+ git clone https://github.com/ZeroKwok/PyUtilScripts.git
47
+ cd PyUtilScripts
48
+ pip install .
49
+ ```
50
+
51
+ ---
52
+
53
+ ## 📝 使用说明
54
+
55
+ - **fcopy**
56
+ - 基于清单文件的复制工具
57
+ - 特点
58
+ - 支持 更新、覆盖写、重命名模式
59
+ - 支持 交互模式,精准把控拷贝细节(拷贝前生成行动列表,在用户编辑或确认后,才具体执行行动列表中记录的动作)
60
+ - 支持 过滤模式,忽略某些文件或目录
61
+ - 示例:
62
+ - 按文件清单拷贝指定目录下的文件
63
+ - 更新模式 `fcopy -l /path/to/list.txt -s /path/to/src -t /path/to/dest`
64
+ - 覆盖模式 `fcopy -l /path/to/list.txt -s /path/to/src -t /path/to/dest -m o`
65
+ - 重命名模式 `fcopy -l /path/to/list.txt -s /path/to/src -t /path/to/dest -m r`
66
+ - 通过指定目录下的文件生成文件清单
67
+ - `fcopy -l /path/to/list.txt -s /path/to/src --update-list`
68
+ - 交互模式下拷贝指定目录的文件
69
+ - `fcopy -l /path/to/list.txt -s /path/to/src -t /path/to/dest -i`
70
+ - 概念
71
+ - 文件清单(fcopy.list)决定要拷贝的文件
72
+ - 行动清单决定拷贝行为(交互模式下通过编辑器呈现)
73
+
74
+ - **prunedirs**
75
+ - 递归删除空目录
76
+ - 示例:
77
+ - `prunedirs /path/to/dir`
78
+
79
+ - **forward.tcp**
80
+ - TCP 端口转发工具
81
+ - 示例:
82
+ - `forward.tcp -s 0.0.0.0:8081 -d 127.0.0.1:1081`
@@ -0,0 +1,52 @@
1
+ # **PyUtilScripts**
2
+
3
+ `PyUtilScripts` 是一个基于 Python 的通用小工具集合,目标是提供编写通用任务的辅助工具。
4
+
5
+ ## 📦 安装
6
+
7
+ ### 通过 pip 安装
8
+
9
+ ```bash
10
+ pip install pyutilscripts
11
+ ```
12
+
13
+ ### 从源码安装
14
+
15
+ ```bash
16
+ git clone https://github.com/ZeroKwok/PyUtilScripts.git
17
+ cd PyUtilScripts
18
+ pip install .
19
+ ```
20
+
21
+ ---
22
+
23
+ ## 📝 使用说明
24
+
25
+ - **fcopy**
26
+ - 基于清单文件的复制工具
27
+ - 特点
28
+ - 支持 更新、覆盖写、重命名模式
29
+ - 支持 交互模式,精准把控拷贝细节(拷贝前生成行动列表,在用户编辑或确认后,才具体执行行动列表中记录的动作)
30
+ - 支持 过滤模式,忽略某些文件或目录
31
+ - 示例:
32
+ - 按文件清单拷贝指定目录下的文件
33
+ - 更新模式 `fcopy -l /path/to/list.txt -s /path/to/src -t /path/to/dest`
34
+ - 覆盖模式 `fcopy -l /path/to/list.txt -s /path/to/src -t /path/to/dest -m o`
35
+ - 重命名模式 `fcopy -l /path/to/list.txt -s /path/to/src -t /path/to/dest -m r`
36
+ - 通过指定目录下的文件生成文件清单
37
+ - `fcopy -l /path/to/list.txt -s /path/to/src --update-list`
38
+ - 交互模式下拷贝指定目录的文件
39
+ - `fcopy -l /path/to/list.txt -s /path/to/src -t /path/to/dest -i`
40
+ - 概念
41
+ - 文件清单(fcopy.list)决定要拷贝的文件
42
+ - 行动清单决定拷贝行为(交互模式下通过编辑器呈现)
43
+
44
+ - **prunedirs**
45
+ - 递归删除空目录
46
+ - 示例:
47
+ - `prunedirs /path/to/dir`
48
+
49
+ - **forward.tcp**
50
+ - TCP 端口转发工具
51
+ - 示例:
52
+ - `forward.tcp -s 0.0.0.0:8081 -d 127.0.0.1:1081`
@@ -10,6 +10,7 @@ keywords = ["tools"]
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.7"
12
12
  license = {text = "MIT License"}
13
+ dynamic = ["version"]
13
14
  classifiers = [
14
15
  "Intended Audience :: Developers",
15
16
  "Topic :: Software Development :: Build Tools",
@@ -24,9 +25,16 @@ classifiers = [
24
25
  "Programming Language :: Python :: 3.11",
25
26
  ]
26
27
  dependencies = [
28
+ "click",
29
+ "natsort",
27
30
  "termcolor",
28
31
  ]
29
- dynamic = ["version"]
32
+
33
+ [project.optional-dependencies]
34
+ dev = [
35
+ "pytest",
36
+ "pytest-cov",
37
+ ]
30
38
 
31
39
  [tool.setuptools.dynamic]
32
40
  version = {attr = "pyutilscripts.projectVersion"}
@@ -2,8 +2,8 @@
2
2
  PyUtilScripts 是一个基于 Python 的通用小工具集合,目标是提供编写通用任务的辅助工具。
3
3
  """
4
4
 
5
- __version__ = "0.3.0"
6
- __status__ = "beta0"
5
+ __version__ = "0.5.1"
6
+ __status__ = ""
7
7
  __author__ = "zero <zero.kwok@foxmail.com>"
8
8
 
9
9
  projectName = 'PyUtilScripts'
@@ -11,6 +11,8 @@
11
11
  import os
12
12
  import re
13
13
  import sys
14
+ import stat
15
+ import math
14
16
  import shlex
15
17
  import click
16
18
  import shutil
@@ -29,18 +31,21 @@ from . import utils
29
31
 
30
32
  CopyModes = ["update", "overwrite", "rename", "u", "o", "r"]
31
33
 
32
- ListFileHeader = """# File list generated by fcopy on {}
34
+ ListFileHead = """# File list generated by fcopy on {Date}
33
35
  # One file per line, relative to source directory
36
+ # From : {Source}
37
+ # Count : {Count}
34
38
  """
35
39
 
36
- ActionsFileHeader = """# Action plan for file copying (edit this file to change actions)
37
- # Actions:
40
+ ActionFileHead = """# Action plan for file copying (edit this file to change actions)
38
41
  #
42
+ # Actions:
39
43
  # c - Copy, when target doesn't exist
40
44
  # u - Update, when target exists with mismatched metadata but source is newer
41
45
  # o - Overwrite, when target exists, unconditionally copy and overwrite (--mode overwrite)
42
46
  # 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
47
+ # m - Make directory, when target is a directory and doesn't exist
48
+ # i - Ignore, when target exists with mismatched metadata but deep comparison shows no difference or source is older
44
49
  # s - Skip, when target exists and no differences found in shallow or deep comparison
45
50
  #
46
51
  # Example:
@@ -48,8 +53,62 @@ ActionsFileHeader = """# Action plan for file copying (edit this file to change
48
53
  # r file3.txt -> file(3).txt Copy and Rename to file(3).txt
49
54
  # o file2.txt Overwrite
50
55
  # s file2.txt Skipped because the files are the same
56
+ #
57
+ # Source Directory: {Source}
58
+ # Target Directory: {Target}
59
+ # Action Count : {Count}
51
60
  """
52
61
 
62
+ ActionNames = {'c': 'Copying', 'u': 'Updating', 'o': 'Replacing',
63
+ 'r': 'Renaming', 'm': 'MakingDir', 'i': 'Ignored', 's': 'Skipped'}
64
+
65
+ def output(level, *args, **kwargs):
66
+ """
67
+ Output messages with specified level.
68
+ 0 - error (red, stderr)
69
+ 1 - warning (yellow, stderr) - can be treated as error in strict mode
70
+ 2 - normal output (stdout)
71
+ 3 - verbose (stdout, only when verbose enabled)
72
+ """
73
+
74
+ # Extract control parameters
75
+ verbose_mode = kwargs.pop('verbose', False)
76
+ strict_mode = kwargs.pop('strict', False)
77
+
78
+ # Handle verbose filtering
79
+ if level > 2 and not verbose_mode:
80
+ return
81
+
82
+ # Handle strict mode: upgrade warnings to errors
83
+ if level == 1 and strict_mode:
84
+ level = 0
85
+
86
+ # Add appropriate prefixes
87
+ if len(args) == 0:
88
+ args = ('',)
89
+
90
+ if level == 0:
91
+ args = ('Error: ' + args[0], *args[1:],)
92
+ elif level == 1:
93
+ args = ('Warning: ' + args[0], *args[1:],)
94
+
95
+ # Set output destination and color
96
+ if level == 0:
97
+ kwargs.setdefault('file', sys.stderr)
98
+ kwargs.setdefault('color', 'red')
99
+ elif level == 1:
100
+ kwargs.setdefault('file', sys.stderr)
101
+ kwargs.setdefault('color', 'yellow')
102
+ elif level == 2:
103
+ kwargs.setdefault('file', sys.stdout)
104
+ elif level == 3:
105
+ kwargs.setdefault('file', sys.stdout)
106
+ kwargs.setdefault('color', 'blue') # Optional: different color for verbose
107
+
108
+ # Output the message
109
+ cprint(*args, **kwargs)
110
+
111
+
53
112
  def read_file_list(filename, comment='#', keep_comments=False):
54
113
  """Read the list of files to copy from the manifest file."""
55
114
  if filename is None:
@@ -66,11 +125,13 @@ def read_file_list(filename, comment='#', keep_comments=False):
66
125
  files.append(line)
67
126
  return files
68
127
 
128
+
69
129
  def filter_match(file, patterns):
70
130
  if not patterns:
71
131
  return False
72
132
  return any(p.match(file) for p in patterns)
73
133
 
134
+
74
135
  def read_file_filter(filename, comment='#'):
75
136
  patterns = []
76
137
  try:
@@ -80,21 +141,28 @@ def read_file_filter(filename, comment='#'):
80
141
  pass # ignore missing file
81
142
  return patterns
82
143
 
144
+
83
145
  def make_file_list(source, filters=[], verbose=False):
84
- files = []
85
- for root, _, names in os.walk(source):
146
+ def handle_files(files, root, names):
86
147
  for n in names:
87
148
  filename = os.path.join(root, n)
88
149
  filename = os.path.relpath(filename, start=source)
89
150
  if filter_match(filename, filters):
90
- if verbose:
91
- cprint(f"Filtered: {filename}", "yellow")
151
+ output(3, f"Filtered: {filename}", verbose=verbose)
92
152
  continue
93
153
  files.append(filename)
94
154
 
155
+ dirs = []
156
+ files = []
157
+ for root, _dirs, _files in os.walk(source):
158
+ handle_files(dirs, root, _dirs)
159
+ handle_files(files, root, _files)
160
+
95
161
  date = datetime.now().isoformat(timespec='seconds')
96
- head = ListFileHeader.format(date).splitlines()
97
- return head + natsorted(files)
162
+ info = f'{len(dirs)} directories, {len(files)} files'
163
+ head = ListFileHead.format(Date=date, Source=source, Count=info).splitlines()
164
+ return head + [''] + natsorted(dirs + files)
165
+
98
166
 
99
167
  def update_file_list(args):
100
168
  """Update the file list with the current contents of the source directory."""
@@ -129,13 +197,14 @@ def update_file_list(args):
129
197
  cprint(f"\nUpdate {args.list} with these changes? [y/N]", end=" ")
130
198
  confirm = input().strip().lower()
131
199
  if confirm != 'y':
132
- cprint('User Cancelled', "red", file=sys.stderr)
200
+ output(0, 'User Cancelled')
133
201
  return 1
134
202
 
135
203
  with open(args.list, 'w') as f:
136
204
  f.write('\n'.join(new))
137
205
  return 0
138
206
 
207
+
139
208
  def file_cmp(file1, file2, stat1, stat2) -> Tuple[bool, int]:
140
209
  """Compare two files and return a tuple of (is_same, meta_cmp)."""
141
210
  # 浅比较, 比较文件大小和修改时间
@@ -160,6 +229,7 @@ def file_cmp(file1, file2, stat1, stat2) -> Tuple[bool, int]:
160
229
  return True, meta_cmp
161
230
  return False, meta_cmp
162
231
 
232
+
163
233
  def increment_filename(directory, filename, rename_list):
164
234
  """
165
235
  在指定目录中为给定文件名生成不冲突的新文件名(仅文件名部分,不含路径)。
@@ -198,6 +268,7 @@ def increment_filename(directory, filename, rename_list):
198
268
 
199
269
  return components / candidate
200
270
 
271
+
201
272
  class Action:
202
273
  """Action类用于描述操作行为"""
203
274
  def __init__(self, action: str, src: str, dst: str = '', common: str = None):
@@ -210,32 +281,40 @@ class Action:
210
281
  return iter((self.action, self.src, self.dst))
211
282
 
212
283
  def natsorted(actions):
213
- priority = {c: i for i,c in enumerate(['c', 'u', 'o', 'r', 'i', 's'])}
284
+ priority = {c: i for i,c in enumerate(['m', 'c', 'u', 'o', 'r', 'i', 's'])}
214
285
  return natsorted(actions, key=lambda a: (priority[a.action], a.src, a.dst))
215
286
 
287
+
216
288
  def make_actions(args):
217
289
  items: List[Action] = []
218
290
  rename_list: List[str] = []
219
291
 
220
292
  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
293
  source = os.path.normpath(os.path.join(args.source, file))
227
294
  target = os.path.normpath(os.path.join(args.target, file))
295
+
296
+ # 过滤掉名单中的文件
297
+ if filter_match(source, args.filter_patterns):
298
+ output(2, f"Filtered: {file}", verbose=args.verbose)
299
+ continue
300
+
228
301
  try:
229
302
  stat1 = os.stat(source)
230
303
  except:
231
304
  # 源文件不存在则发出警告, 不视为错误
232
- cprint(f"Warn: SourceFileNotFound -> {source}", "magenta", file=sys.stderr)
305
+ output(1, f"SourceFileNotFound: {file}", strict=args.strict)
233
306
  continue
234
307
 
235
308
  try:
236
309
  stat2 = os.stat(target)
237
310
  except FileNotFoundError:
238
- items.append(Action('c', file))
311
+ if stat.S_ISDIR(stat1.st_mode):
312
+ items.append(Action('m', file))
313
+ else:
314
+ items.append(Action('c', file))
315
+ continue
316
+
317
+ if stat.S_ISDIR(stat1.st_mode): # 目录直接跳过, 因为拷贝文件会尝试创建目录
239
318
  continue
240
319
 
241
320
  if args.mode in ('r', 'rename'):
@@ -250,14 +329,18 @@ def make_actions(args):
250
329
 
251
330
  # update mode
252
331
  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
- )
332
+ def common():
333
+ if args.verbose > 0:
334
+ return (
335
+ [
336
+ f"src: {utils.format_ftime(stat1.st_mtime)}, {utils.format_bytes(stat1.st_size)}",
337
+ f"dst: {utils.format_ftime(stat2.st_mtime)}, {utils.format_bytes(stat2.st_size)}",
338
+ ]
339
+ if meta_cmp != 0
340
+ else None
341
+ )
342
+ else:
343
+ return {0: None, 1: ["src newer",], -1: ["dst newer",]}[meta_cmp]
261
344
 
262
345
  if is_same:
263
346
  items.append(Action('s' if meta_cmp == 0 else 'i', file, common=common()))
@@ -266,6 +349,7 @@ def make_actions(args):
266
349
 
267
350
  return Action.natsorted(items)
268
351
 
352
+
269
353
  def parse_actions(lines, comment='#'):
270
354
  files = []
271
355
  for row, line in enumerate(lines):
@@ -276,11 +360,15 @@ def parse_actions(lines, comment='#'):
276
360
  # Handle action-prefixed lines (for edit mode)
277
361
  if ' ' not in line:
278
362
  raise ValueError(f"Invalid line: {row}: {line}")
363
+ fields = shlex.split(line, posix=os.name != 'nt')
279
364
 
280
- if '->'in line:
281
- fields = shlex.split(line, posix=os.name != 'nt')
282
- else:
283
- fields = line.split(maxsplit=1)
365
+ # remove comments fields
366
+ result = []
367
+ for f in fields:
368
+ if f.startswith(comment):
369
+ break
370
+ result.append(f)
371
+ fields = result
284
372
 
285
373
  if len(fields) == 2:
286
374
  action, file1, file2 = fields + ['']
@@ -292,36 +380,66 @@ def parse_actions(lines, comment='#'):
292
380
 
293
381
  return files
294
382
 
383
+
295
384
  def read_file_actions(filename, comment='#'):
296
385
  return parse_actions(read_file_list(filename, comment, True), comment)
297
386
 
298
- def join_actions(actions:list[Action], header:str, verbose:int):
387
+
388
+ def line_append_space(line, align=16, minLength=32):
389
+ l = len(line)
390
+ n = math.ceil(l / align) * align
391
+ n = max(n, minLength)
392
+ return line + max(n - l, 1) * ' '
393
+
394
+
395
+ def join_actions(actions:list[Action], head:str, args):
299
396
  lines = []
397
+ current = ''
398
+ counter = {}
300
399
  for item in actions:
301
- line = f'{item.action} "{item.src}"'
400
+ # 统计
401
+ if current != item.action:
402
+ current = item.action
403
+ counter[item.action] = 0
404
+ lines.append('')
405
+ lines.append(f"# {ActionNames[item.action]} Files: {{{item.action}}}")
406
+ counter[item.action] += 1
407
+
408
+ line = f'{item.action} "{item.src}"'
302
409
  if item.dst:
303
410
  line += f' -> "{item.dst}"'
411
+ if item.common and args.verbose <= 0:
412
+ line = line_append_space(line) + f'# {",".join(item.common)}'
304
413
  lines.append(line)
305
414
 
306
- for c in item.common or []:
307
- lines.append(f' # {c}')
415
+ if args.verbose > 0: # 注释以独立的行存在
416
+ for c in item.common or []:
417
+ lines.append(f' # {c}')
418
+ info = str(counter).replace("'", "")
419
+ head = head.format(Source=args.source, Target=args.target, Count=info)
420
+ body = "\n".join(lines)
421
+ body = body.format(**counter)
308
422
 
309
- return header.rstrip() + "\n\n" + "\n".join(lines) + "\n"
423
+ return head.rstrip() + "\n" + body + "\n"
310
424
 
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)
425
+
426
+ def print_actions(actions:list, head:str, args):
427
+ output(2)
428
+ output(2, f"The following actions will be performed:", "yellow")
429
+
430
+ lines = join_actions(actions, head, args)
315
431
  for line in lines.splitlines():
316
432
  if not line:
317
- print()
433
+ output(2)
318
434
  continue
319
435
 
320
- a = line[0]
321
- c = {"#": "dark_grey", "s": "yellow", "o": "green", "c": "green", " ": "white"}
436
+ a = line.strip()[0]
437
+ c = {"#": "dark_grey", "s": "yellow", "o": "green",
438
+ "c": "green", "m": "cyan", " ": "white"}
322
439
  f = a if a in c else " "
323
- cprint(line, c[f])
324
- print()
440
+ output(2, line, c[f])
441
+ output(2)
442
+
325
443
 
326
444
  def get_available_editor(defaults=("micro", "nano", "vim", "vi", "notepad")):
327
445
  """检查哪个编辑器可用,返回第一个可用的,否则返回 None"""
@@ -333,71 +451,86 @@ def get_available_editor(defaults=("micro", "nano", "vim", "vi", "notepad")):
333
451
  return editor
334
452
  return None
335
453
 
336
- def edit_actions(actions:list, header:str, verbose:int) -> list:
454
+
455
+ def edit_actions(actions:list, head:str, args) -> list:
337
456
  """
338
457
  使用 click.edit() 启动编辑器让用户编辑行动计划。
339
458
  返回: None 用户取消编辑或没保存
340
459
  """
341
- content = join_actions(actions, header, verbose)
460
+ content = join_actions(actions, head, args)
342
461
 
343
462
  # 打开编辑器让用户编辑内容
344
463
  edited = click.edit(content, extension=".actions-todo", editor=get_available_editor())
345
464
  if edited is None:
346
- cprint("User canceled or didn't save, aborted.", "red", file=sys.stderr)
465
+ output(0, "User canceled or didn't save, aborted.")
347
466
  raise SystemExit()
348
467
 
349
468
  # 解析用户编辑后的结果
350
469
  return parse_actions(edited.splitlines(), '#')
351
470
 
471
+
352
472
  def copy_files(args):
353
473
  """Copy files from source directory to target directory with specified manifest"""
354
474
  args.manifest = read_file_list(args.list)
355
475
  if not args.manifest:
356
- cprint('Error: list file is empty or invalid.', "red", file=sys.stderr)
476
+ output(0, 'list file is empty or invalid.')
357
477
  return 1
358
478
 
359
479
  actions = make_actions(args)
360
480
  if not actions:
361
- cprint("Error: No actions to perform.", "red", file=sys.stderr)
481
+ output(0, "Error: No actions to perform.")
362
482
  return 1
363
483
 
364
484
  if args.interactive:
365
- actions = edit_actions(actions, ActionsFileHeader, args.verbose)
485
+ actions = edit_actions(actions, ActionFileHead, args)
366
486
  elif args.dry_run or args.verbose > 1:
367
- print_actions(actions, ActionsFileHeader, args.verbose)
487
+ print_actions(actions, ActionFileHead, args)
368
488
 
369
489
  copied, skipped = 0, 0
370
490
  for action, file1, file2 in actions:
371
491
  file2 = file2 or file1
372
- if action == 's':
492
+ if action in ('s', 'i'):
373
493
  skipped += 1
374
494
  continue
375
495
 
496
+ source = os.path.normpath(os.path.join(args.source, file1))
497
+ target = os.path.normpath(os.path.join(args.target, file2))
498
+
376
499
  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
500
  if os.path.isdir(source):
380
501
  continue
381
502
 
382
- prefix = {'c': 'Copying', 'o': 'Replacing', 'u': 'Updating', 'r': 'Renaming'}[action]
503
+ prefix = ActionNames[action]
383
504
  if args.dry_run:
384
- cprint(f"Dry run: {prefix}: {file1} -> {file2}", "cyan")
505
+ output(2, f"Dry run: {prefix}: {file1} -> {file2}", "cyan")
385
506
  elif args.verbose > 0:
386
- cprint(f"{prefix} {file1} -> {file2}", 'green')
507
+ output(2, f"{prefix} {file1} -> {file2}", 'green')
387
508
  else:
388
- cprint(f"{prefix} {file1}", 'green')
509
+ output(2, f"{prefix} {file1}", 'green')
389
510
 
390
511
  if not args.dry_run:
391
512
  try:
392
513
  os.makedirs(os.path.dirname(target), exist_ok=True)
393
514
  shutil.copy2(source, target)
394
515
  except OSError as e:
395
- cprint(f"Error copying {source} to {target}: {e}", "red", file=sys.stderr)
516
+ output(0, f"{prefix} {source} to {target}: {e}")
517
+ if args.strict:
518
+ return 1
396
519
  copied += 1
397
520
 
398
- cprint(f"Done. {copied} files copied, {skipped} skipped.")
521
+ elif action == 'm':
522
+ try:
523
+ os.makedirs(target, exist_ok=True)
524
+ except OSError as e:
525
+ output(0, f"{prefix} {source} to {target}: {e}")
526
+ if args.strict:
527
+ return 1
528
+ copied += 1
529
+
530
+ output(2, f"Done. {copied} files copied, {skipped} skipped.")
399
531
  return 0
400
532
 
533
+
401
534
  def main():
402
535
  try:
403
536
  parser = argparse.ArgumentParser(
@@ -410,9 +543,10 @@ def main():
410
543
  parser.add_argument("-m", "--mode", default="update", choices=CopyModes, help="copy mode: u|update, o|overwrite, r|rename")
411
544
  parser.add_argument("-i", "--interactive", action="store_true", help="Let the user edit the list of action plans to copy")
412
545
  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.')
546
+ parser.add_argument('--filter', help='file containing blacklist regex patterns, one per line.')
414
547
  parser.add_argument("--update-list", action="store_true", help="update the --list file with current --source contents (with confirmation)")
415
548
  parser.add_argument("--dry-run", action="store_true", help="simulate operations without actually copying files")
549
+ parser.add_argument("--strict", action="store_true", help="treat warnings as errors (exit with non-zero code on warnings)")
416
550
  parser.add_argument('--debug', action='store_true', default=False, help=argparse.SUPPRESS)
417
551
 
418
552
  try:
@@ -431,36 +565,37 @@ def main():
431
565
  input('Wait for debugging and press Enter to continue...')
432
566
 
433
567
  if not args.list or not args.source:
434
- cprint("Error: Please provide the required arguments.", "red", file=sys.stderr)
568
+ output(0, "Please provide the required arguments.")
435
569
  parser.print_help()
436
570
  return 1
437
571
 
438
572
  args.mode = args.mode.lower()
439
573
  args.source = os.path.normpath(os.path.abspath(args.source))
440
574
  if not os.path.isdir(args.source):
441
- cprint(f"Error: Source directory '{args.source}' does not exist", "red", file=sys.stderr)
575
+ output(0, f"Source directory '{args.source}' does not exist")
442
576
  return 1
443
577
 
444
578
  # Read the filter file
445
579
  args.filter_patterns = read_file_filter(args.filter)
446
580
  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")
581
+ output(1, f"No valid patterns found in filter file '{args.filter}'.", strict=args.strict)
448
582
 
449
583
  # Check if running in the terminal, because editor is only available in terminal
450
584
  if args.interactive and not sys.stdout.isatty():
451
- cprint("Warning: Not running in the terminal (may be a redirect or pipe)", "yellow")
585
+ output(1, "Not running in the terminal (may be a redirect or pipe)", strict=args.strict)
452
586
 
453
587
  if args.update_list:
454
588
  return update_file_list(args)
455
589
  else:
456
590
  if args.target is None:
457
- cprint(f"Error: Please provide the target directory.")
591
+ output(0, "Please provide the target directory.")
458
592
  return 1
459
593
  args.target = os.path.normpath(os.path.abspath(args.target))
460
594
  return copy_files(args)
461
595
 
462
596
  except KeyboardInterrupt:
463
- cprint('\nKeyboard Interrupt', 'red', end='')
597
+ output(2)
598
+ output(0, "Keyboard Interrupt", end='')
464
599
  return 1
465
600
 
466
601
  if __name__ == "__main__":
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyutilscripts
3
+ Version: 0.5.1
4
+ Summary: PyUtilScripts 是一个基于 Python 的通用小工具集合,目标是提供编写通用任务的辅助工具。
5
+ Author-email: Zero Kwok <zero.kwok@foxmail.com>
6
+ License: MIT License
7
+ Project-URL: Homepage, https://github.com/ZeroKwok/pyutilscripts
8
+ Project-URL: Issues, https://github.com/ZeroKwok/pyutilscripts/issues
9
+ Keywords: tools
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Topic :: Software Development :: Build Tools
12
+ Classifier: Programming Language :: Python
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.7
16
+ Classifier: Programming Language :: Python :: 3.8
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Requires-Python: >=3.7
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: click
24
+ Requires-Dist: natsort
25
+ Requires-Dist: termcolor
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest; extra == "dev"
28
+ Requires-Dist: pytest-cov; extra == "dev"
29
+ Dynamic: license-file
30
+
31
+ # **PyUtilScripts**
32
+
33
+ `PyUtilScripts` 是一个基于 Python 的通用小工具集合,目标是提供编写通用任务的辅助工具。
34
+
35
+ ## 📦 安装
36
+
37
+ ### 通过 pip 安装
38
+
39
+ ```bash
40
+ pip install pyutilscripts
41
+ ```
42
+
43
+ ### 从源码安装
44
+
45
+ ```bash
46
+ git clone https://github.com/ZeroKwok/PyUtilScripts.git
47
+ cd PyUtilScripts
48
+ pip install .
49
+ ```
50
+
51
+ ---
52
+
53
+ ## 📝 使用说明
54
+
55
+ - **fcopy**
56
+ - 基于清单文件的复制工具
57
+ - 特点
58
+ - 支持 更新、覆盖写、重命名模式
59
+ - 支持 交互模式,精准把控拷贝细节(拷贝前生成行动列表,在用户编辑或确认后,才具体执行行动列表中记录的动作)
60
+ - 支持 过滤模式,忽略某些文件或目录
61
+ - 示例:
62
+ - 按文件清单拷贝指定目录下的文件
63
+ - 更新模式 `fcopy -l /path/to/list.txt -s /path/to/src -t /path/to/dest`
64
+ - 覆盖模式 `fcopy -l /path/to/list.txt -s /path/to/src -t /path/to/dest -m o`
65
+ - 重命名模式 `fcopy -l /path/to/list.txt -s /path/to/src -t /path/to/dest -m r`
66
+ - 通过指定目录下的文件生成文件清单
67
+ - `fcopy -l /path/to/list.txt -s /path/to/src --update-list`
68
+ - 交互模式下拷贝指定目录的文件
69
+ - `fcopy -l /path/to/list.txt -s /path/to/src -t /path/to/dest -i`
70
+ - 概念
71
+ - 文件清单(fcopy.list)决定要拷贝的文件
72
+ - 行动清单决定拷贝行为(交互模式下通过编辑器呈现)
73
+
74
+ - **prunedirs**
75
+ - 递归删除空目录
76
+ - 示例:
77
+ - `prunedirs /path/to/dir`
78
+
79
+ - **forward.tcp**
80
+ - TCP 端口转发工具
81
+ - 示例:
82
+ - `forward.tcp -s 0.0.0.0:8081 -d 127.0.0.1:1081`
@@ -12,5 +12,6 @@ pyutilscripts.egg-info/entry_points.txt
12
12
  pyutilscripts.egg-info/requires.txt
13
13
  pyutilscripts.egg-info/top_level.txt
14
14
  pyutilscripts/utils/__init__.py
15
+ tests/test_action_parser.py
15
16
  tests/test_fcopy.py
16
17
  tests/test_fcopy_cli.py
@@ -0,0 +1,7 @@
1
+ click
2
+ natsort
3
+ termcolor
4
+
5
+ [dev]
6
+ pytest
7
+ pytest-cov
@@ -0,0 +1,63 @@
1
+
2
+ import os
3
+ import shlex
4
+ import pytest
5
+ from unittest import mock
6
+ from pyutilscripts import fcopy
7
+
8
+ valid = [
9
+ 'c file1.txt',
10
+ 'c file3.txt -> file(3).txt',
11
+ ' s file2.txt -> file(2).txt',
12
+ ' s "fi le2.txt" -> "fi le(2).txt"',
13
+ " s 'fi le2.txt' -> 'fi le(2).txt'",
14
+ ' s "fi le2.txt"',
15
+ " s 'fi #le2.txt'",
16
+ 'o file2.txt',
17
+ 'o sss\\file2.txt',
18
+ 'o sss//file2.txt',
19
+
20
+ 'o sss//file2##.txt',
21
+ 'o sss//file2##.txt # comment here',
22
+ 'o sss//file2##.txt #comment here',
23
+ " s 'fi le2.txt' -> 'fi le(2).txt' #comment",
24
+ ]
25
+
26
+ invalid = [
27
+ ' s fi le2.txt"',
28
+ ' s "fi le2.txt',
29
+ ]
30
+
31
+ def debug_actions_parse():
32
+ for index, line in enumerate(valid + invalid, 0):
33
+ try:
34
+ fields = shlex.split(line, posix=os.name != 'nt')
35
+ print(f'{index} fields: {fields}')
36
+
37
+ # remove comments fields
38
+ result = []
39
+ for f in fields:
40
+ if f.startswith('#'):
41
+ break
42
+ result.append(f)
43
+ fields = result
44
+
45
+ if len(fields) == 2:
46
+ action, file1, file2 = fields + ['']
47
+ elif len(fields) >= 4 and '->' in fields:
48
+ action, file1, _, file2 = fields
49
+ else:
50
+ print(f' Invalid line: {line}')
51
+ continue
52
+
53
+ print(' parse as: ', action, file1, file2)
54
+ except Exception as e:
55
+ print(f' Invalid line: {line}, error: {e}')
56
+ continue
57
+
58
+ def test_actions_parse(monkeypatch):
59
+ files = fcopy.parse_actions(valid)
60
+ assert len(files) == len(valid)
61
+ with pytest.raises(ValueError):
62
+ files = fcopy.parse_actions(invalid)
63
+
@@ -23,6 +23,7 @@ def dircmp(dir1, dir2):
23
23
  len(result.left_only) == 0
24
24
  and len(result.right_only) == 0
25
25
  and len(result.diff_files) == 0 ):
26
+ print(f"Directory comparison failed: {result.left_only}, {result.right_only}, {result.diff_files}")
26
27
  return False
27
28
  for dir in result.common_dirs:
28
29
  if not dircmp(os.path.join(dir1, dir), os.path.join(dir2, dir)):
@@ -35,7 +36,7 @@ def test_update_list(monkeypatch, file_manifest):
35
36
 
36
37
  def test_copy_files_with_update_and_rename(monkeypatch, file_manifest):
37
38
  target = tempfile.mktemp()
38
- monkeypatch.setattr(sys, "argv", ["fcopy.py", "-s", ".", "-l", file_manifest, "-t", target])
39
+ monkeypatch.setattr(sys, "argv", ["fcopy.py", "-s", ".", "-l", file_manifest, "-t", target, "-vv"])
39
40
  code = fcopy.main()
40
41
  assert code == 0
41
42
 
@@ -68,7 +69,7 @@ def test_update_list_with_filter(monkeypatch, file_manifest):
68
69
  called = {}
69
70
  def fake_read_file_filter(args):
70
71
  called['ok'] = True
71
- return [re.compile(line) for line in ['^file.txt$', '^.+__pycache__.+$', '^\.git.+$']]
72
+ return [re.compile(line) for line in ['^file.txt$', '^.+?__pycache__.+$', '^\.git.+$']]
72
73
  monkeypatch.setattr("pyutilscripts.fcopy.read_file_filter", fake_read_file_filter)
73
74
  code = fcopy.main()
74
75
  assert code == 0
@@ -78,7 +79,7 @@ def test_update_list_with_filter(monkeypatch, file_manifest):
78
79
  right = fcopy.read_file_list(manifest)
79
80
  diff = set(left) - set(right)
80
81
  assert diff
81
- assert 'file.txt' in diff
82
+ assert [f for f in diff if '__pycache__' in f]
82
83
  assert len(diff) > 2
83
84
 
84
85
  def test_copy_files_with_filter(monkeypatch, file_manifest):
@@ -1,55 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: pyutilscripts
3
- Version: 0.3.0b0
4
- Summary: PyUtilScripts 是一个基于 Python 的通用小工具集合,目标是提供编写通用任务的辅助工具。
5
- Author-email: Zero Kwok <zero.kwok@foxmail.com>
6
- License: MIT License
7
- Project-URL: Homepage, https://github.com/ZeroKwok/pyutilscripts
8
- Project-URL: Issues, https://github.com/ZeroKwok/pyutilscripts/issues
9
- Keywords: tools
10
- Classifier: Intended Audience :: Developers
11
- Classifier: Topic :: Software Development :: Build Tools
12
- Classifier: Programming Language :: Python
13
- Classifier: Programming Language :: Python :: 3 :: Only
14
- Classifier: Programming Language :: Python :: 3
15
- Classifier: Programming Language :: Python :: 3.7
16
- Classifier: Programming Language :: Python :: 3.8
17
- Classifier: Programming Language :: Python :: 3.9
18
- Classifier: Programming Language :: Python :: 3.10
19
- Classifier: Programming Language :: Python :: 3.11
20
- Requires-Python: >=3.7
21
- Description-Content-Type: text/markdown
22
- License-File: LICENSE
23
- Requires-Dist: termcolor
24
- Dynamic: license-file
25
-
26
- # **PyUtilScripts**
27
-
28
- `PyUtilScripts` 是一个基于 Python 的通用小工具集合,目标是提供编写通用任务的辅助工具。
29
-
30
- ## **📜 脚本列表**
31
-
32
- | 脚本名称 | 功能描述 | 示例用法 |
33
- |----------|---------|---------|
34
- | [`fcopy.py`](fcopy.py) | 基于清单文件的复制工具 | `fcopy -l list.txt -s src_dir -d dest_dir` |
35
- | [`forward-tcp.py`](forward-tcp.py) | TCP 端口转发工具 | `forward-tcp -s 0.0.0.0:8081 -d 127.0.0.1:1081` |
36
- | [`prunedirs.py`](prunedirs.py) | 递归删除空目录 | `prunedirs /path/to/dir` |
37
-
38
- ---
39
-
40
- ## **⚙️ 安装与使用**
41
-
42
- ### **1. 克隆仓库**
43
-
44
- ```bash
45
- git clone https://github.com/ZeroKwok/PyUtilScripts.git
46
- cd PyUtilScripts
47
- ```
48
-
49
- ### **2. 直接运行(无需安装)**
50
-
51
- 所有脚本均可直接执行:
52
-
53
- ```bash
54
- python3 script_name.py [args]
55
- ```
@@ -1,30 +0,0 @@
1
- # **PyUtilScripts**
2
-
3
- `PyUtilScripts` 是一个基于 Python 的通用小工具集合,目标是提供编写通用任务的辅助工具。
4
-
5
- ## **📜 脚本列表**
6
-
7
- | 脚本名称 | 功能描述 | 示例用法 |
8
- |----------|---------|---------|
9
- | [`fcopy.py`](fcopy.py) | 基于清单文件的复制工具 | `fcopy -l list.txt -s src_dir -d dest_dir` |
10
- | [`forward-tcp.py`](forward-tcp.py) | TCP 端口转发工具 | `forward-tcp -s 0.0.0.0:8081 -d 127.0.0.1:1081` |
11
- | [`prunedirs.py`](prunedirs.py) | 递归删除空目录 | `prunedirs /path/to/dir` |
12
-
13
- ---
14
-
15
- ## **⚙️ 安装与使用**
16
-
17
- ### **1. 克隆仓库**
18
-
19
- ```bash
20
- git clone https://github.com/ZeroKwok/PyUtilScripts.git
21
- cd PyUtilScripts
22
- ```
23
-
24
- ### **2. 直接运行(无需安装)**
25
-
26
- 所有脚本均可直接执行:
27
-
28
- ```bash
29
- python3 script_name.py [args]
30
- ```
@@ -1,55 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: pyutilscripts
3
- Version: 0.3.0b0
4
- Summary: PyUtilScripts 是一个基于 Python 的通用小工具集合,目标是提供编写通用任务的辅助工具。
5
- Author-email: Zero Kwok <zero.kwok@foxmail.com>
6
- License: MIT License
7
- Project-URL: Homepage, https://github.com/ZeroKwok/pyutilscripts
8
- Project-URL: Issues, https://github.com/ZeroKwok/pyutilscripts/issues
9
- Keywords: tools
10
- Classifier: Intended Audience :: Developers
11
- Classifier: Topic :: Software Development :: Build Tools
12
- Classifier: Programming Language :: Python
13
- Classifier: Programming Language :: Python :: 3 :: Only
14
- Classifier: Programming Language :: Python :: 3
15
- Classifier: Programming Language :: Python :: 3.7
16
- Classifier: Programming Language :: Python :: 3.8
17
- Classifier: Programming Language :: Python :: 3.9
18
- Classifier: Programming Language :: Python :: 3.10
19
- Classifier: Programming Language :: Python :: 3.11
20
- Requires-Python: >=3.7
21
- Description-Content-Type: text/markdown
22
- License-File: LICENSE
23
- Requires-Dist: termcolor
24
- Dynamic: license-file
25
-
26
- # **PyUtilScripts**
27
-
28
- `PyUtilScripts` 是一个基于 Python 的通用小工具集合,目标是提供编写通用任务的辅助工具。
29
-
30
- ## **📜 脚本列表**
31
-
32
- | 脚本名称 | 功能描述 | 示例用法 |
33
- |----------|---------|---------|
34
- | [`fcopy.py`](fcopy.py) | 基于清单文件的复制工具 | `fcopy -l list.txt -s src_dir -d dest_dir` |
35
- | [`forward-tcp.py`](forward-tcp.py) | TCP 端口转发工具 | `forward-tcp -s 0.0.0.0:8081 -d 127.0.0.1:1081` |
36
- | [`prunedirs.py`](prunedirs.py) | 递归删除空目录 | `prunedirs /path/to/dir` |
37
-
38
- ---
39
-
40
- ## **⚙️ 安装与使用**
41
-
42
- ### **1. 克隆仓库**
43
-
44
- ```bash
45
- git clone https://github.com/ZeroKwok/PyUtilScripts.git
46
- cd PyUtilScripts
47
- ```
48
-
49
- ### **2. 直接运行(无需安装)**
50
-
51
- 所有脚本均可直接执行:
52
-
53
- ```bash
54
- python3 script_name.py [args]
55
- ```
@@ -1 +0,0 @@
1
- termcolor
File without changes
File without changes