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.
- pyutilscripts-0.5.1/PKG-INFO +82 -0
- pyutilscripts-0.5.1/README.md +52 -0
- {pyutilscripts-0.3.0b0 → pyutilscripts-0.5.1}/pyproject.toml +9 -1
- {pyutilscripts-0.3.0b0 → pyutilscripts-0.5.1}/pyutilscripts/__init__.py +2 -2
- {pyutilscripts-0.3.0b0 → pyutilscripts-0.5.1}/pyutilscripts/fcopy.py +203 -68
- pyutilscripts-0.5.1/pyutilscripts.egg-info/PKG-INFO +82 -0
- {pyutilscripts-0.3.0b0 → pyutilscripts-0.5.1}/pyutilscripts.egg-info/SOURCES.txt +1 -0
- pyutilscripts-0.5.1/pyutilscripts.egg-info/requires.txt +7 -0
- pyutilscripts-0.5.1/tests/test_action_parser.py +63 -0
- {pyutilscripts-0.3.0b0 → pyutilscripts-0.5.1}/tests/test_fcopy.py +4 -3
- pyutilscripts-0.3.0b0/PKG-INFO +0 -55
- pyutilscripts-0.3.0b0/README.md +0 -30
- pyutilscripts-0.3.0b0/pyutilscripts.egg-info/PKG-INFO +0 -55
- pyutilscripts-0.3.0b0/pyutilscripts.egg-info/requires.txt +0 -1
- {pyutilscripts-0.3.0b0 → pyutilscripts-0.5.1}/LICENSE +0 -0
- {pyutilscripts-0.3.0b0 → pyutilscripts-0.5.1}/pyutilscripts/forward_tcp.py +0 -0
- {pyutilscripts-0.3.0b0 → pyutilscripts-0.5.1}/pyutilscripts/prunedirs.py +0 -0
- {pyutilscripts-0.3.0b0 → pyutilscripts-0.5.1}/pyutilscripts/utils/__init__.py +0 -0
- {pyutilscripts-0.3.0b0 → pyutilscripts-0.5.1}/pyutilscripts.egg-info/dependency_links.txt +0 -0
- {pyutilscripts-0.3.0b0 → pyutilscripts-0.5.1}/pyutilscripts.egg-info/entry_points.txt +0 -0
- {pyutilscripts-0.3.0b0 → pyutilscripts-0.5.1}/pyutilscripts.egg-info/top_level.txt +0 -0
- {pyutilscripts-0.3.0b0 → pyutilscripts-0.5.1}/setup.cfg +0 -0
- {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
|
-
|
|
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"}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
307
|
-
|
|
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
|
|
423
|
+
return head.rstrip() + "\n" + body + "\n"
|
|
310
424
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
433
|
+
output(2)
|
|
318
434
|
continue
|
|
319
435
|
|
|
320
|
-
a = line[0]
|
|
321
|
-
c = {"#": "dark_grey", "s": "yellow", "o": "green",
|
|
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
|
-
|
|
324
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
481
|
+
output(0, "Error: No actions to perform.")
|
|
362
482
|
return 1
|
|
363
483
|
|
|
364
484
|
if args.interactive:
|
|
365
|
-
actions = edit_actions(actions,
|
|
485
|
+
actions = edit_actions(actions, ActionFileHead, args)
|
|
366
486
|
elif args.dry_run or args.verbose > 1:
|
|
367
|
-
print_actions(actions,
|
|
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
|
|
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 =
|
|
503
|
+
prefix = ActionNames[action]
|
|
383
504
|
if args.dry_run:
|
|
384
|
-
|
|
505
|
+
output(2, f"Dry run: {prefix}: {file1} -> {file2}", "cyan")
|
|
385
506
|
elif args.verbose > 0:
|
|
386
|
-
|
|
507
|
+
output(2, f"{prefix} {file1} -> {file2}", 'green')
|
|
387
508
|
else:
|
|
388
|
-
|
|
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
|
-
|
|
516
|
+
output(0, f"{prefix} {source} to {target}: {e}")
|
|
517
|
+
if args.strict:
|
|
518
|
+
return 1
|
|
396
519
|
copied += 1
|
|
397
520
|
|
|
398
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
@@ -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$', '
|
|
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 '
|
|
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):
|
pyutilscripts-0.3.0b0/PKG-INFO
DELETED
|
@@ -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
|
-
```
|
pyutilscripts-0.3.0b0/README.md
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|