pyutilscripts 0.8.0__tar.gz → 0.10.0__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 (30) hide show
  1. pyutilscripts-0.10.0/.editorconfig +13 -0
  2. pyutilscripts-0.10.0/.github/workflows/python-publish.yml +70 -0
  3. pyutilscripts-0.10.0/.gitignore +5 -0
  4. pyutilscripts-0.10.0/Changelog.md +60 -0
  5. {pyutilscripts-0.8.0/pyutilscripts.egg-info → pyutilscripts-0.10.0}/PKG-INFO +11 -10
  6. pyutilscripts-0.10.0/TODO.md +28 -0
  7. pyutilscripts-0.10.0/makefile +28 -0
  8. {pyutilscripts-0.8.0 → pyutilscripts-0.10.0}/pyproject.toml +10 -6
  9. pyutilscripts-0.10.0/pytest.ini +4 -0
  10. {pyutilscripts-0.8.0 → pyutilscripts-0.10.0}/pyutilscripts/__init__.py +1 -1
  11. {pyutilscripts-0.8.0 → pyutilscripts-0.10.0}/pyutilscripts/fcopy.py +17 -14
  12. pyutilscripts-0.10.0/pyutilscripts/httpd.py +105 -0
  13. pyutilscripts-0.10.0/pyutilscripts/ntraffic.py +269 -0
  14. pyutilscripts-0.10.0/pyutilscripts/ntunnel.py +173 -0
  15. {pyutilscripts-0.8.0 → pyutilscripts-0.10.0}/pyutilscripts/utils/__init__.py +7 -5
  16. pyutilscripts-0.8.0/PKG-INFO +0 -82
  17. pyutilscripts-0.8.0/pyutilscripts.egg-info/SOURCES.txt +0 -18
  18. pyutilscripts-0.8.0/pyutilscripts.egg-info/dependency_links.txt +0 -1
  19. pyutilscripts-0.8.0/pyutilscripts.egg-info/entry_points.txt +0 -4
  20. pyutilscripts-0.8.0/pyutilscripts.egg-info/requires.txt +0 -7
  21. pyutilscripts-0.8.0/pyutilscripts.egg-info/top_level.txt +0 -1
  22. pyutilscripts-0.8.0/setup.cfg +0 -4
  23. {pyutilscripts-0.8.0 → pyutilscripts-0.10.0}/LICENSE +0 -0
  24. {pyutilscripts-0.8.0 → pyutilscripts-0.10.0}/README.md +0 -0
  25. {pyutilscripts-0.8.0 → pyutilscripts-0.10.0}/pyutilscripts/forward_tcp.py +0 -0
  26. {pyutilscripts-0.8.0 → pyutilscripts-0.10.0}/pyutilscripts/prunedirs.py +0 -0
  27. {pyutilscripts-0.8.0 → pyutilscripts-0.10.0}/pyutilscripts/toast.py +0 -0
  28. {pyutilscripts-0.8.0 → pyutilscripts-0.10.0}/tests/test_action_parser.py +0 -0
  29. {pyutilscripts-0.8.0 → pyutilscripts-0.10.0}/tests/test_fcopy.py +0 -0
  30. {pyutilscripts-0.8.0 → pyutilscripts-0.10.0}/tests/test_fcopy_cli.py +0 -0
@@ -0,0 +1,13 @@
1
+ # Editor configuration, see https://editorconfig.org
2
+ root = true
3
+
4
+ [*]
5
+ charset = utf-8
6
+ indent_style = space
7
+ indent_size = 4
8
+ trim_trailing_whitespace = true
9
+ insert_final_newline = true
10
+
11
+ # 下面只覆盖不一样的配置,其他全部继承 [*]
12
+ [*.md]
13
+ indent_size = 2
@@ -0,0 +1,70 @@
1
+ # This workflow will upload a Python Package to PyPI when a release is created
2
+ # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries
3
+
4
+ # This workflow uses actions that are not certified by GitHub.
5
+ # They are provided by a third-party and are governed by
6
+ # separate terms of service, privacy policy, and support
7
+ # documentation.
8
+
9
+ name: Upload Python Package
10
+
11
+ on:
12
+ release:
13
+ types: [published]
14
+
15
+ permissions:
16
+ contents: read
17
+
18
+ jobs:
19
+ release-build:
20
+ runs-on: ubuntu-latest
21
+
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+
25
+ - uses: actions/setup-python@v5
26
+ with:
27
+ python-version: "3.x"
28
+
29
+ - name: Build release distributions
30
+ run: |
31
+ # NOTE: put your own distribution build steps here.
32
+ python -m pip install build
33
+ python -m build
34
+
35
+ - name: Upload distributions
36
+ uses: actions/upload-artifact@v4
37
+ with:
38
+ name: release-dists
39
+ path: dist/
40
+
41
+ pypi-publish:
42
+ runs-on: ubuntu-latest
43
+ needs:
44
+ - release-build
45
+ permissions:
46
+ # IMPORTANT: this permission is mandatory for trusted publishing
47
+ id-token: write
48
+
49
+ # Dedicated environments with protections for publishing are strongly recommended.
50
+ # For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules
51
+ environment:
52
+ name: pypi
53
+ # OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status:
54
+ # url: https://pypi.org/p/YOURPROJECT
55
+ #
56
+ # ALTERNATIVE: if your GitHub Release name is the PyPI project version string
57
+ # ALTERNATIVE: exactly, uncomment the following line instead:
58
+ # url: https://pypi.org/project/YOURPROJECT/${{ github.event.release.name }}
59
+
60
+ steps:
61
+ - name: Retrieve release distributions
62
+ uses: actions/download-artifact@v4
63
+ with:
64
+ name: release-dists
65
+ path: dist/
66
+
67
+ - name: Publish release distributions to PyPI
68
+ uses: pypa/gh-action-pypi-publish@release/v1
69
+ with:
70
+ packages-dir: dist/
@@ -0,0 +1,5 @@
1
+ __pycache__
2
+ .vscode
3
+ *.egg-info
4
+ build
5
+ dist
@@ -0,0 +1,60 @@
1
+ # Changelog
2
+
3
+ ## v0.10.0
4
+
5
+ - fcopy 优化 交互模式的行动清单, 将说明注释放在文档尾部
6
+ - ntunnel 添加 一个简单的网络工具, 基于 TUN 设备的 UPD/TCP 隧道,用于向远端转发 IP报文
7
+
8
+ ## v0.9.0
9
+
10
+ - httpd 添加 http.server 限速版的文件服务器
11
+
12
+ ## v0.8.0
13
+
14
+ - toast 添加 windows toast 通知支持
15
+
16
+ ## v0.7.0
17
+
18
+ - fcopy 调整了运行时与verbose模式的输出, 提升用户体验
19
+ - fcopy 调整了默认编辑器优先级, 优先使用 Nano, 可通过 `export EDITOR=vim` 覆盖
20
+ - fcopy 调整了行动清单的输出与注释,增强可读性
21
+ - fcopy 修复一些bugs: --filter 为None导致的异常, Windows下因为BOM 文件头不能解析的问题
22
+
23
+ ## v0.6.0
24
+
25
+ - fcopy 重构了行动列表与执行输出的内容, 使宏观行为更加清晰
26
+ - fcopy 调整了过滤逻辑, --filter 选项去除默认值, 输入文件无效视为致命错误
27
+
28
+ ## v0.5.0
29
+
30
+ - fcopy 添加空目录支持
31
+ - fcopy 添加文件统计信息
32
+
33
+ ## v0.4.0
34
+
35
+ - fcopy 添加严格模式, 将警告视为错误
36
+ - fcopy 优化交互内容, 增强用户体验
37
+
38
+ ## v0.3.1
39
+
40
+ - fcopy 交互模式: 行动列表支持行尾注释
41
+ - fcopy 交互模式: 添加文件属性注释, 以便了解文件为何会被忽略
42
+
43
+ ## v0.3.0
44
+
45
+ - fcopy 添加文件清单生成模式
46
+ - fcopy 添加单元测试覆盖
47
+ - fcopy 修复拷贝逻辑Bug
48
+
49
+ ## v0.2.0
50
+
51
+ - fcopy 添加更新、覆盖、重命名模式
52
+ - fcopy 支持交互模式(生成行动列表,在用户编辑或确认后,才开始执行)
53
+ - fcopy 添加过滤模式
54
+
55
+ ## v0.1.0
56
+
57
+ - Initial release.
58
+ - fcopy.py
59
+ - prunedirs.py
60
+ - forward-tcp.py
@@ -1,32 +1,33 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyutilscripts
3
- Version: 0.8.0
3
+ Version: 0.10.0
4
4
  Summary: PyUtilScripts 是一个基于 Python 的通用小工具集合,目标是提供编写通用任务的辅助工具。
5
- Author-email: Zero Kwok <zero.kwok@foxmail.com>
6
- License: MIT License
7
5
  Project-URL: Homepage, https://github.com/ZeroKwok/pyutilscripts
8
6
  Project-URL: Issues, https://github.com/ZeroKwok/pyutilscripts/issues
7
+ Author-email: Zero Kwok <zero.kwok@foxmail.com>
8
+ License: MIT License
9
+ License-File: LICENSE
9
10
  Keywords: tools
10
11
  Classifier: Intended Audience :: Developers
11
- Classifier: Topic :: Software Development :: Build Tools
12
12
  Classifier: Programming Language :: Python
13
- Classifier: Programming Language :: Python :: 3 :: Only
14
13
  Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
15
  Classifier: Programming Language :: Python :: 3.7
16
16
  Classifier: Programming Language :: Python :: 3.8
17
17
  Classifier: Programming Language :: Python :: 3.9
18
18
  Classifier: Programming Language :: Python :: 3.10
19
19
  Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Topic :: Software Development :: Build Tools
20
21
  Requires-Python: >=3.7
21
- Description-Content-Type: text/markdown
22
- License-File: LICENSE
23
22
  Requires-Dist: click
24
23
  Requires-Dist: natsort
24
+ Requires-Dist: pytun-pmd3
25
25
  Requires-Dist: termcolor
26
26
  Provides-Extra: dev
27
- Requires-Dist: pytest; extra == "dev"
28
- Requires-Dist: pytest-cov; extra == "dev"
29
- Dynamic: license-file
27
+ Requires-Dist: debugpy; extra == 'dev'
28
+ Requires-Dist: pytest; extra == 'dev'
29
+ Requires-Dist: pytest-cov; extra == 'dev'
30
+ Description-Content-Type: text/markdown
30
31
 
31
32
  # **PyUtilScripts**
32
33
 
@@ -0,0 +1,28 @@
1
+ # TODO
2
+
3
+ `PyUtilScripts` 是一个基于 Python 的通用小工具集合,目标是提供便捷的日常任务自动化/辅助工具,帮助开发者、运维人员或普通用户提升效率。
4
+
5
+ ## 近期
6
+
7
+ - [x] fcopy --actions 清单细化行动模式
8
+ - [x] c 拷贝, 即目标不存在
9
+ - [x] s 跳过, 即目标存在, 深/浅对比无差异, 视为文件无更新
10
+ - [x] i 忽略, 即目标存在, 元数据对比不一致, 但深对比无差异, 忽略
11
+ - [x] u 更新, 即目标存在, 元数据对比不一致, 但源文件更新鲜
12
+ - [x] o 覆盖, 即目标存在, 无条件拷贝并覆盖(--mode overwrite)
13
+ - [x] r 重命名, 即目标存在, 拷贝并递增文件名(--mode rename)
14
+ - [x] m 创建目录, 源文件是目录, 且目标不存在 (确保空目录被创建)
15
+ - [x] fcopy --actions 清单 action 为 u 时, 添加两个文件的修改时间注释
16
+ - [x] fcopy --filter 添加正则匹配过滤
17
+ - [x] fcopy --actions 清单支持行尾注释
18
+ - [x] fcopy 增加严格模式, 避免在拷贝时源文件不存在, 导致结果不如预期
19
+ - [x] fcopy --actions 清单添加原/目标目录, 与必要的注释
20
+ - [x] fcopy 支持空目录
21
+ - [x] fcopy --filter 过滤掉的文件, 应该出现在清单文件中, f
22
+ - [x] fcopy -i 交互模式下, 源目录中不存在的文件, 应该出现在清单文件中, m
23
+ - [x] fcopy 因拷贝创建的目录是冗余的, 因此只有空目录才体现在清单中, e
24
+ - [x] fcopy -i 交互模式下, 拷贝时不应出现: 过滤/缺失/忽略的文件警告, 仅在非交互模式下显示
25
+ - [x] fcopy -i 交互模式下, 如果存被过滤的项, 则显示过滤文件名, 便于调试
26
+ - [x] fcopy 优化 交互模式的行动清单, 将说明注释放在文档尾部
27
+ - [ ] fcopy 支持 将目标目录中, 未在 --list 清单中指定的文件, 移动至回收站(待定)
28
+ - [ ] fcopy 增加 忽略警告模式
@@ -0,0 +1,28 @@
1
+ .PHONY: build upload test clean
2
+
3
+ build: clean
4
+ @echo "Building..."
5
+ python -m build
6
+
7
+ upload:
8
+ @echo "Uploading..."
9
+ twine upload --repository testpypi dist/*
10
+ twine upload dist/*
11
+
12
+ setup:
13
+ @echo "Setting up..."
14
+ python -m pip install .
15
+
16
+ test: clean
17
+ @echo "Testing..."
18
+ python -m pip install -e .[dev]
19
+ pytest tests
20
+
21
+ venv:
22
+ @echo "Setting up virtual environment..."
23
+ python -m venv venv
24
+ source venv/bin/activate
25
+ pip install -e .[dev]
26
+
27
+ clean:
28
+ rm -fr dist/*
@@ -1,6 +1,9 @@
1
1
  [build-system]
2
- requires = ["setuptools"]
3
- build-backend = "setuptools.build_meta"
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [tool.hatch.version]
6
+ path = "pyutilscripts/__init__.py"
4
7
 
5
8
  [project]
6
9
  name = "pyutilscripts"
@@ -28,17 +31,16 @@ dependencies = [
28
31
  "click",
29
32
  "natsort",
30
33
  "termcolor",
34
+ "pytun_pmd3",
31
35
  ]
32
36
 
33
37
  [project.optional-dependencies]
34
38
  dev = [
39
+ "debugpy",
35
40
  "pytest",
36
41
  "pytest-cov",
37
42
  ]
38
43
 
39
- [tool.setuptools.dynamic]
40
- version = {attr = "pyutilscripts.projectVersion"}
41
-
42
44
  [project.urls]
43
45
  Homepage = "https://github.com/ZeroKwok/pyutilscripts"
44
46
  Issues = "https://github.com/ZeroKwok/pyutilscripts/issues"
@@ -46,4 +48,6 @@ Issues = "https://github.com/ZeroKwok/pyutilscripts/issues"
46
48
  [project.scripts]
47
49
  "fcopy" = "pyutilscripts.fcopy:main"
48
50
  "prunedirs" = "pyutilscripts.prunedirs:main"
49
- "forward.tcp" = "pyutilscripts.forward_tcp:main"
51
+ "forward.tcp" = "pyutilscripts.forward_tcp:main"
52
+ "httpd" = "pyutilscripts.httpd:main"
53
+ "ntunnel" = "pyutilscripts.ntunnel:main"
@@ -0,0 +1,4 @@
1
+ [pytest]
2
+ testpaths = tests
3
+ python_files = test_*.py
4
+ python_functions = test_*
@@ -2,7 +2,7 @@
2
2
  PyUtilScripts 是一个基于 Python 的通用小工具集合,目标是提供编写通用任务的辅助工具。
3
3
  """
4
4
 
5
- __version__ = "0.8.0"
5
+ __version__ = "0.10.0"
6
6
  __status__ = ""
7
7
  __author__ = "zero <zero.kwok@foxmail.com>"
8
8
 
@@ -37,8 +37,16 @@ ListFileHead = """# File list generated by fcopy on {Date}
37
37
  # Count : {Count}
38
38
  """
39
39
 
40
- ActionFileHead = """# Action plan for file copying (edit this file to change actions)
40
+ ActionFileHead = """
41
+ # fcopy action plan — edit to change copying actions (see end of this file for details)
41
42
  #
43
+ # Source Directory: {Source}
44
+ # Target Directory: {Target}
45
+ # Blacklist : {Filter}
46
+ # Action Count : {Count}
47
+ """
48
+
49
+ ActionFileTail = """
42
50
  # Actions:
43
51
  # c - Copy : Target doesn't exist
44
52
  # u - Update : Target exists with metadata mismatch, source is newer
@@ -56,11 +64,6 @@ ActionFileHead = """# Action plan for file copying (edit this file to change act
56
64
  # r file3.txt -> file(3).txt Copy and Rename to file(3).txt
57
65
  # i file2.txt # file:=, meta:> Files are the same and source file is newer
58
66
  # i file2.txt # file:≠, meta:< Files are different and source is older
59
- #
60
- # Source Directory: {Source}
61
- # Target Directory: {Target}
62
- # Blacklist : {Filter}
63
- # Action Count : {Count}
64
67
  """
65
68
 
66
69
  ActionPriority = ["c", "u", "o", "r", "e", "m", "i", "f", "s"]
@@ -449,7 +452,7 @@ def line_append_space(line, align=16, minLength=32):
449
452
  return line + max(n - l, 1) * " "
450
453
 
451
454
 
452
- def join_actions(actions: list[Action], head: str, args):
455
+ def join_actions(actions: list[Action], head: str, tail: str, args):
453
456
  def comment1(prefix, same, cmp):
454
457
  return f'{prefix} file:{["≠", "="][same]}, meta:{["<", ">"][cmp == 1]}'
455
458
  def comment2(prefix, stat):
@@ -489,10 +492,10 @@ def join_actions(actions: list[Action], head: str, args):
489
492
  body = "\n".join(lines)
490
493
  body = body.format(**counter)
491
494
 
492
- return head.rstrip() + "\n" + body + "\n"
495
+ return head.strip() + "\n" + body + "\n" + tail.strip()
493
496
 
494
497
 
495
- def print_actions(actions: list, head: str, args):
498
+ def print_actions(actions: list, head: str, tail: str, args):
496
499
  output(2, f"\nThe following actions will be performed:\n", "yellow")
497
500
  colors = {
498
501
  "#": "dark_grey",
@@ -500,7 +503,7 @@ def print_actions(actions: list, head: str, args):
500
503
  }
501
504
  colors.update(ActionColors)
502
505
 
503
- lines = join_actions(actions, head, args)
506
+ lines = join_actions(actions, head, tail, args)
504
507
  for line in lines.splitlines():
505
508
  if not line:
506
509
  output(2)
@@ -523,12 +526,12 @@ def get_available_editor(defaults=["nano", "vim", "vi", "notepad"]):
523
526
  return None
524
527
 
525
528
 
526
- def edit_actions(actions: list, head: str, args) -> list:
529
+ def edit_actions(actions: list, head: str, tail: str, args) -> list:
527
530
  """
528
531
  使用 click.edit() 启动编辑器让用户编辑行动计划。
529
532
  返回: None 用户取消编辑或没保存
530
533
  """
531
- content = join_actions(actions, head, args)
534
+ content = join_actions(actions, head, tail, args)
532
535
 
533
536
  # 打开编辑器让用户编辑内容
534
537
  edited = click.edit(
@@ -555,9 +558,9 @@ def copy_files(args):
555
558
  return 1
556
559
 
557
560
  if args.interactive:
558
- actions = edit_actions(actions, ActionFileHead, args)
561
+ actions = edit_actions(actions, ActionFileHead, ActionFileTail, args)
559
562
  elif args.dry_run or args.verbose > 1:
560
- print_actions(actions, ActionFileHead, args)
563
+ print_actions(actions, ActionFileHead, ActionFileTail, args)
561
564
  output(2)
562
565
 
563
566
  copied, skipped, missing, filtered = 0, 0, 0, 0
@@ -0,0 +1,105 @@
1
+ #! python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ import time
5
+ import argparse
6
+ import sys
7
+ import os
8
+ from http.server import HTTPServer, SimpleHTTPRequestHandler
9
+ from socketserver import ThreadingMixIn
10
+
11
+ class RateLimitHandler(SimpleHTTPRequestHandler):
12
+ # Default value
13
+ SPEED_LIMIT = -1
14
+
15
+ def copyfile(self, source, outputfile):
16
+ """Override copyfile to implement rate limiting. If unlimited, use the original efficient method."""
17
+ # If unlimited, call parent class method (which uses shutil.copyfileobj internally and is more efficient)
18
+ if self.SPEED_LIMIT <= 0:
19
+ return super().copyfile(source, outputfile)
20
+
21
+ chunk_size = 1024 * 64 # 64KB chunk size
22
+ try:
23
+ start_time = time.time()
24
+ bytes_sent = 0
25
+ while True:
26
+ data = source.read(chunk_size)
27
+ if not data:
28
+ break
29
+ outputfile.write(data)
30
+ bytes_sent += len(data)
31
+
32
+ # Rate limiting calculation
33
+ elapsed = time.time() - start_time
34
+ expected_time = bytes_sent / self.SPEED_LIMIT
35
+
36
+ if elapsed < expected_time:
37
+ # 最小睡眠时间设为 0.5 秒, 因此理论最低速度约为 0.5 * 64kb = 128KB/s
38
+ time.sleep(min(expected_time - elapsed, 0.5))
39
+ except (ConnectionResetError, BrokenPipeError):
40
+ print(f"\n[!] Client {self.client_address[0]} disconnected")
41
+
42
+ class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
43
+ daemon_threads = True
44
+
45
+ def parse_speed(speed_str):
46
+ """Parse speed string; support negative for unlimited."""
47
+ s = speed_str.upper()
48
+ try:
49
+ # Handle unlimited cases
50
+ if s == '-1' or s == '0' or s == 'UNLIMITED':
51
+ return -1
52
+
53
+ if s.endswith('MB'):
54
+ return int(float(s[:-2]) * 1024 * 1024)
55
+ elif s.endswith('KB'):
56
+ return int(float(s[:-2]) * 1024)
57
+ else:
58
+ return int(s)
59
+ except ValueError:
60
+ raise argparse.ArgumentTypeError("Invalid speed format. Use '1MB', '500KB', '-1' (unlimited), or a byte value.")
61
+
62
+ def main():
63
+ parser = argparse.ArgumentParser(description="Multithreaded HTTP file server with rate limiting")
64
+ parser.add_argument('--port', '-p', type=int, default=8000, help='Port to listen on (default: 8000)')
65
+ parser.add_argument('--bind', '-b', default='0.0.0.0', help='Bind address (default: 0.0.0.0)')
66
+ parser.add_argument('--limit', '-l', default='-1', help="Rate limit (e.g., 1MB, 500KB). Use -1 for unlimited")
67
+ parser.add_argument('--dir', '-d', default='.', help='Root directory to serve (default: current directory)')
68
+
69
+ args = parser.parse_args()
70
+
71
+ # 1. Verify directory exists
72
+ if not os.path.isdir(args.dir):
73
+ print(f"[ERROR] Directory does not exist: {args.dir}")
74
+ sys.exit(1)
75
+
76
+ # 2. Change to and record absolute path
77
+ abs_path = os.path.abspath(args.dir)
78
+ os.chdir(abs_path)
79
+
80
+ # 3. Parse rate limit
81
+ limit_bps = parse_speed(args.limit)
82
+ limit_display = f"{args.limit}/s ({limit_bps})" if limit_bps > 0 else "Unlimited"
83
+
84
+ # 4. Dynamic handler class
85
+ HandlerClass = type('CustomHandler', (RateLimitHandler,), {'SPEED_LIMIT': limit_bps})
86
+
87
+ server_address = (args.bind, args.port)
88
+ httpd = ThreadedHTTPServer(server_address, HandlerClass)
89
+
90
+ print(f"{'='*45}")
91
+ print(f"[*] HTTP server started")
92
+ print(f"[*] Listening on: http://{args.bind}:{args.port}")
93
+ print(f"[*] Serving directory: {abs_path}")
94
+ print(f"[*] Speed limit: {limit_display}")
95
+ print(f"{'='*45}")
96
+ print("[!] Press Ctrl+C to stop the server\n")
97
+
98
+ try:
99
+ httpd.serve_forever()
100
+ except KeyboardInterrupt:
101
+ print("\n[!] Shutting down server...")
102
+ httpd.server_close()
103
+
104
+ if __name__ == "__main__":
105
+ main()
@@ -0,0 +1,269 @@
1
+ #! python
2
+ # -*- coding: utf-8 -*-
3
+ #
4
+ # This file is part of the PyUtilScripts project.
5
+ # Copyright (c) 2020-2026 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
+ import time
11
+ import struct
12
+ import socket
13
+ import logging
14
+ import threading
15
+ import contextlib
16
+
17
+ prefix = b'\xef\x5a'
18
+ logger = logging.getLogger(__name__)
19
+
20
+ class AnyEndpoint:
21
+ def __init__(self):
22
+ self.sock = None
23
+ self.peers = None
24
+
25
+ self.tx_bytes = 0 # 发送字节数
26
+ self.rx_bytes = 0 # 接收字节数
27
+ self.tx_packets = 0 # 发送包数
28
+ self.rx_packets = 0 # 接收包数
29
+ self.stats_time = time.time()
30
+ self.stats_lock = threading.Lock()
31
+
32
+ def add_tx(self, tx_bytes=0, tx_packets=1):
33
+ with self.stats_lock:
34
+ self.tx_bytes += tx_bytes
35
+ self.tx_packets += tx_packets
36
+
37
+ def add_rx(self, rx_bytes=0, rx_packets=1):
38
+ with self.stats_lock:
39
+ self.rx_bytes += rx_bytes
40
+ self.rx_packets += rx_packets
41
+
42
+ def stats(self, reset_timer:bool=True):
43
+ with self.stats_lock:
44
+ elapsed = time.time() - self.stats_time
45
+ if reset_timer:
46
+ self.stats_time = time.time()
47
+ tx_rate = self.tx_bytes / elapsed if elapsed > 0 else 0
48
+ rx_rate = self.rx_bytes / elapsed if elapsed > 0 else 0
49
+ return (elapsed, (self.tx_packets, self.tx_bytes, tx_rate), (self.rx_packets, self.rx_bytes, rx_rate))
50
+
51
+
52
+ class UDPEndpoint(AnyEndpoint):
53
+ def __init__(self, addr, peers = None):
54
+ super().__init__()
55
+ self.type = 'UDP'
56
+ self.addr = addr
57
+ self.peers = peers
58
+ self.closed = True
59
+
60
+ def listen(self):
61
+ self.close()
62
+ try:
63
+ self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
64
+ self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
65
+ self.sock.bind(self.addr)
66
+ self.addr = self.sock.getsockname()
67
+ self.closed = False
68
+ except OSError as e:
69
+ logger.error(f"UDPEndpoint listen failed on {self.addr}: {e}")
70
+ self.close()
71
+ return False
72
+
73
+ def establish(self) -> bool:
74
+ if self.closed or not self.sock:
75
+ logger.error("UDPEndpoint establish: socket not ready")
76
+ return False
77
+ self.send_packet(prefix + b'HELLO') # 握手, 目的是传送地址
78
+ return True
79
+
80
+ def close(self):
81
+ if self.sock:
82
+ self.closed = True
83
+ with contextlib.suppress(Exception):
84
+ self.sock.close()
85
+ self.sock = None
86
+
87
+ def release(self):
88
+ self.close()
89
+
90
+ def send_packet(self, data):
91
+ if not self.sock:
92
+ logger.error("UDPEndpoint send: socket not created")
93
+ return False
94
+ if self.peers is None:
95
+ logger.debug("UDPEndpoint send: no peers address")
96
+ return False
97
+
98
+ try:
99
+ self.sock.sendto(data, self.peers)
100
+ self.add_tx(len(data))
101
+ return True
102
+ except Exception as e:
103
+ logger.error(f"UDPEndpoint send failed to {self.peers}: {e}")
104
+ return False
105
+
106
+ def recv_packet(self):
107
+ if not self.sock or self.closed:
108
+ return None
109
+
110
+ try:
111
+ data, addr = self.sock.recvfrom(65535)
112
+ self.add_rx(len(data))
113
+
114
+ if data.startswith(prefix):
115
+ if data[2:] == b'HELLO':
116
+ self.peers = addr
117
+ logger.debug(f"UDPEndpoint recv HELLO from {addr}")
118
+ return data
119
+ except socket.timeout:
120
+ return None
121
+ except ConnectionResetError as e: # 对端未监听端口
122
+ return None
123
+ except OSError as e:
124
+ if not self.closed:
125
+ logger.error(f"UDPEndpoint recv error: {e}")
126
+ return None
127
+
128
+
129
+
130
+ class TCPEndpoint(AnyEndpoint):
131
+ def __init__(self, addr, peers = None, timeout=5):
132
+ super().__init__()
133
+ self.type = 'TCP'
134
+ self.addr = addr
135
+ self.peers = peers
136
+ self.timeout = timeout
137
+ self.connected = False
138
+ self.listen_sock = None
139
+
140
+ def listen(self):
141
+ if self.peers: # client mode not need to listen
142
+ return True
143
+ if self.listen_sock: # already listening
144
+ return True
145
+ if self.connected or not self.addr:
146
+ return False
147
+
148
+ try:
149
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
150
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
151
+ sock.settimeout(self.timeout)
152
+ sock.bind(self.addr)
153
+ sock.listen(1)
154
+ self.listen_sock = sock
155
+ return True
156
+ except OSError as e:
157
+ logger.error(f"TCP listen failed on {self.addr}: {str(e)}", exc_info=True)
158
+ if 'sock' in locals() and sock:
159
+ sock.close()
160
+ return False
161
+
162
+ def connect(self):
163
+ logger.debug(f"TCP connect to {self.addr} ...")
164
+ if self.connected:
165
+ return True
166
+
167
+ try:
168
+ self.close()
169
+ self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
170
+ self.sock.settimeout(self.timeout)
171
+ self.sock.connect(self.peers)
172
+ self.connected = True
173
+ logger.info(f"[+] TCP connected to {self.peers}")
174
+ return True
175
+ except OSError as e:
176
+ logger.error(f"TCPEndpoint connect failed to {self.peers}: {e}")
177
+ if self.sock:
178
+ self.close()
179
+ return False
180
+
181
+ def accept(self):
182
+ if not self.listen_sock:
183
+ logger.error("TCPEndpoint accept: no listen socket")
184
+ return False
185
+ if self.connected:
186
+ return True
187
+
188
+ try:
189
+ self.close()
190
+ self.sock, self.peers = self.listen_sock.accept()
191
+ self.sock.settimeout(self.timeout)
192
+ self.connected = True
193
+ logger.debug(f"TCPEndpoint accept: connected to {self.peers}")
194
+ return True
195
+ except socket.timeout:
196
+ return False
197
+ except OSError as e:
198
+ if self.listen_sock:
199
+ logger.error(f"TCPEndpoint accept error: {e}")
200
+ if self.sock:
201
+ self.close()
202
+ return False
203
+
204
+ def establish(self) -> bool:
205
+ if not self.connected:
206
+ if self.listen_sock:
207
+ return self.accept()
208
+ else:
209
+ return self.connect()
210
+ return True
211
+
212
+ def close(self):
213
+ self.connected = False
214
+ if self.sock:
215
+ with contextlib.suppress(Exception):
216
+ self.sock.close()
217
+ self.sock = None
218
+
219
+ def release(self):
220
+ self.close()
221
+ if self.listen_sock:
222
+ with contextlib.suppress(Exception):
223
+ self.listen_sock.close()
224
+ self.listen_sock = None
225
+
226
+ def send_packet(self, data):
227
+ if not self.establish():
228
+ logger.debug("TCPEndpoint send packet: failed to establish connection")
229
+ return False
230
+ try:
231
+ self.sock.sendall(struct.pack('!I', len(data)) + data)
232
+ self.add_tx(len(data))
233
+ return True
234
+ except OSError as e:
235
+ logger.error(f"TCPEndpoint send failed to {self.peers}: {e}")
236
+ self.close()
237
+ return False
238
+
239
+ def recv_packet(self):
240
+ if not self.establish():
241
+ logger.debug("TCPEndpoint recv packet: failed to establish connection")
242
+ return None
243
+ try:
244
+ length = self._recv_exact(4)
245
+ if not length:
246
+ return None
247
+ length = struct.unpack('!I', length)[0]
248
+ data = self._recv_exact(length)
249
+ self.add_rx(len(data))
250
+ return data
251
+ except OSError as e:
252
+ logger.error(f"TCPEndpoint recv failed from {self.peers}: {e}")
253
+ self.close()
254
+ return None
255
+
256
+ def _recv_exact(self, size):
257
+ data = b''
258
+ while len(data) < size:
259
+ try:
260
+ chunk = self.sock.recv(size - len(data))
261
+ if not chunk: # EOF
262
+ self.close()
263
+ return None
264
+ data += chunk
265
+ except socket.timeout:
266
+ logger.warning(f"TCPEndpoint recv timeout, peer={self.peers}")
267
+ self.close()
268
+ return None
269
+ return data
@@ -0,0 +1,173 @@
1
+ #! python
2
+ # -*- coding: utf-8 -*-
3
+ #
4
+ # This file is part of the PyUtilScripts project.
5
+ # Copyright (c) 2020-2026 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
+ import sys
11
+ import time
12
+ import logging
13
+ import argparse
14
+ import traceback
15
+ import threading
16
+ import contextlib
17
+
18
+ from .utils import *
19
+ from .ntraffic import UDPEndpoint, TCPEndpoint, AnyEndpoint
20
+
21
+ running = True
22
+
23
+ def create_tun(name:str, addr: tuple, mtu=1400):
24
+ try:
25
+ import pytun_pmd3 as pytun
26
+ if sys.platform == 'win32':
27
+ tun = pytun.TunTapDevice(name=name)
28
+ else:
29
+ tun = pytun.TunTapDevice(name=name, flags=pytun.linux.IFF_NO_PI | pytun.linux.IFF_TUN)
30
+ tun.addr = addr
31
+ tun.mtu = mtu
32
+ tun.up()
33
+ return tun
34
+ except ImportError:
35
+ print('[!] pytun module not found. Please install "pytun-pmd3" using pip.')
36
+ except Exception as e:
37
+ print(f"[!] Error creating TUN device: {e}")
38
+ exit(1)
39
+
40
+ def forward_tun_to_peers(tun, endpoint:AnyEndpoint, args:dict):
41
+ global running
42
+ while running:
43
+ try:
44
+ packet = tun.read(tun.mtu)
45
+ if packet:
46
+ args.debug and print_packet(packet, prefix='[TUN -> PEER]')
47
+ endpoint.send_packet(packet)
48
+ except TimeoutError:
49
+ continue
50
+ except Exception as e:
51
+ if running:
52
+ print(f"[!] Error in [TUN -> PEER]: {traceback.format_exc()}")
53
+
54
+ def forward_peers_to_tun(tun, endpoint:AnyEndpoint, args:dict):
55
+ global running
56
+ while running:
57
+ try:
58
+ data = endpoint.recv_packet()
59
+ if data:
60
+ args.debug and print_packet(data, prefix='[TUN <- PEER]')
61
+ with contextlib.suppress(OSError): # 忽略无效数据包引发的无效参数异常
62
+ tun.write(data)
63
+ else:
64
+ time.sleep(1)
65
+ except Exception as e:
66
+ print(f"[!] Error in [TUN <- PEER]: {traceback.format_exc()}")
67
+
68
+ def print_packet(packet, prefix=''):
69
+ src = dst = "N/A"
70
+ version = packet[0] >> 4
71
+ if version == 4:
72
+ src, dst = packet[12:16], packet[16:20]
73
+ src, dst = ".".join(map(str, src)), ".".join(map(str, dst))
74
+ elif version == 6:
75
+ src, dst = packet[8:24], packet[24:40]
76
+ src, dst = src.hex(':'), dst.hex(':')
77
+ print(f"{prefix}: {src} -> {dst} (len={len(packet)})")
78
+
79
+ def report_stats(args, endpoint: AnyEndpoint):
80
+ while True:
81
+ time.sleep(args.stats_interval)
82
+ (elapsed, (tx_packets, tx_bytes, tx_rate), (rx_packets, rx_bytes, rx_rate)) = endpoint.stats()
83
+ tx = f"{tx_packets} pkts {format_bytes(tx_bytes, precision='.2f')} {format_bytes(tx_rate, precision=' 7.1f', postfix='/s')}"
84
+ rx = f"{rx_packets} pkts {format_bytes(rx_bytes, precision='.2f')} {format_bytes(rx_rate, precision=' 7.1f', postfix='/s')}"
85
+ print(f"[*] {elapsed:06.1f}s TX [{tx}] - RX [{rx}]")
86
+
87
+ def main():
88
+ parser = argparse.ArgumentParser(description="Create a simple TUN to forward IP packets to remote peers")
89
+ parser.add_argument("--name", default="tun0", help="Interface name (default: tun0)")
90
+ parser.add_argument("--addr", default="fd00::1", help="IPv6 Address (default: fd00::1)")
91
+ parser.add_argument("--protocol", default="udp", help="Transport protocol (udp/tcp)")
92
+ parser.add_argument("--remote", type=str, default=None, help="Forward packets to remote endpoint")
93
+ parser.add_argument("--listen", type=str, default='0.0.0.0:8001', help="Listen for incoming packets (default: 0.0.0.0:8001)")
94
+ parser.add_argument("--stats-interval", type=int, default=5, help="Traffic report interval in seconds (default: 5)")
95
+ parser.add_argument("--no-stats", action="store_true", help="Disable traffic statistics reporting")
96
+ parser.add_argument("--debug", action="store_true", help="Enable debug mode")
97
+ args = parser.parse_args()
98
+
99
+ if args.debug:
100
+ print(f"[*] Debug: Starting with arguments: {args}")
101
+ input("Press Enter to continue...")
102
+ logging.root.addHandler(logging.StreamHandler())
103
+ logging.root.setLevel(logging.DEBUG)
104
+ logging.debug("[*] Debug: Logging enabled")
105
+
106
+ def parse_addr(addr, default=None):
107
+ if addr is None:
108
+ return None
109
+ host, port = addr.split(':')
110
+ host = host if host else default[0]
111
+ port = port if port else default[1]
112
+ return (host, int(port))
113
+
114
+ args.remote = parse_addr(args.remote, default=('127.0.0.1', 0))
115
+ args.listen = parse_addr(args.listen, default=('0.0.0.0', 0))
116
+
117
+ if args.protocol.upper() != 'UDP':
118
+ Endpoint = TCPEndpoint
119
+ else:
120
+ Endpoint = UDPEndpoint
121
+
122
+ endpoint = Endpoint(addr=args.listen, peers=args.remote)
123
+ endpoint.listen()
124
+ endpoint.establish()
125
+ print(f"[+] {endpoint.type} socket listen to {endpoint.addr}")
126
+
127
+ tun = create_tun(args.name, args.addr)
128
+ print(f"[+] Interface {tun.name} is UP")
129
+ print(f"[+] IP Address: {args.addr}")
130
+
131
+ # 启动转发线程
132
+ t1 = threading.Thread(target=forward_tun_to_peers, args=(tun, endpoint, args))
133
+ t1.daemon = True
134
+ t1.start()
135
+
136
+ t2 = threading.Thread(target=forward_peers_to_tun, args=(tun, endpoint, args))
137
+ t2.daemon = True
138
+ t2.start()
139
+
140
+ # 启动统计报告线程(如果不禁用)
141
+ if not args.no_stats:
142
+ t_stats = threading.Thread(target=report_stats, args=(args, endpoint))
143
+ t_stats.daemon = True
144
+ t_stats.start()
145
+ print(f"[+] Traffic statistics reporting every {args.stats_interval}s")
146
+ else:
147
+ print("[*] Traffic statistics reporting disabled")
148
+
149
+ print(f"[*] Tunnel is running. Press Ctrl+C to exit.")
150
+ try:
151
+ while True:
152
+ time.sleep(1)
153
+ except KeyboardInterrupt:
154
+ print("\n[!] Shutting down...")
155
+ finally:
156
+ # 最终报告
157
+ if not args.no_stats:
158
+ print("\n[!] Final Traffic Summary:")
159
+ (_, (tx_packets, tx_bytes, _), (rx_packets, rx_bytes, _)) = endpoint.stats()
160
+ print(f" Total TX: {format_bytes(tx_bytes)} ({tx_packets} packets)")
161
+ print(f" Total RX: {format_bytes(rx_bytes)} ({rx_packets} packets)")
162
+ print(f" Total: {format_bytes(tx_bytes + rx_bytes)}")
163
+
164
+ global running
165
+ running = False
166
+ tun.down()
167
+ tun.close()
168
+ endpoint.release()
169
+ print("[+] Cleanup complete.")
170
+
171
+
172
+ if __name__ == "__main__":
173
+ main()
@@ -2,14 +2,16 @@ import math
2
2
  import time
3
3
 
4
4
 
5
- def format_bytes(size_bytes: int, precision: int = 2) -> str:
6
- """自动将字节数格式化为人类可读的单位(B, KB, MB, GB...)"""
5
+ def format_bytes(
6
+ size_bytes: int, precision: str = ".2f", postfix: str = "", base: int = 1024
7
+ ) -> str:
8
+ """将字节数格式化为人类可读形式"""
7
9
  if size_bytes == 0:
8
10
  return "0B"
9
11
  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]}"
12
+ i = int(math.floor(math.log(size_bytes, base)))
13
+ size = size_bytes / (base**i)
14
+ return f"{size:{precision}}{units[i]}"
13
15
 
14
16
 
15
17
  def format_ftime(seconds: float, format: str = "%Y-%m-%d %H:%M"):
@@ -1,82 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: pyutilscripts
3
- Version: 0.8.0
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`
@@ -1,18 +0,0 @@
1
- LICENSE
2
- README.md
3
- pyproject.toml
4
- pyutilscripts/__init__.py
5
- pyutilscripts/fcopy.py
6
- pyutilscripts/forward_tcp.py
7
- pyutilscripts/prunedirs.py
8
- pyutilscripts/toast.py
9
- pyutilscripts.egg-info/PKG-INFO
10
- pyutilscripts.egg-info/SOURCES.txt
11
- pyutilscripts.egg-info/dependency_links.txt
12
- pyutilscripts.egg-info/entry_points.txt
13
- pyutilscripts.egg-info/requires.txt
14
- pyutilscripts.egg-info/top_level.txt
15
- pyutilscripts/utils/__init__.py
16
- tests/test_action_parser.py
17
- tests/test_fcopy.py
18
- tests/test_fcopy_cli.py
@@ -1,4 +0,0 @@
1
- [console_scripts]
2
- fcopy = pyutilscripts.fcopy:main
3
- forward.tcp = pyutilscripts.forward_tcp:main
4
- prunedirs = pyutilscripts.prunedirs:main
@@ -1,7 +0,0 @@
1
- click
2
- natsort
3
- termcolor
4
-
5
- [dev]
6
- pytest
7
- pytest-cov
@@ -1 +0,0 @@
1
- pyutilscripts
@@ -1,4 +0,0 @@
1
- [egg_info]
2
- tag_build =
3
- tag_date = 0
4
-
File without changes
File without changes