开发 Web 服务时,每次改完代码都要手动 Ctrl+C 停掉再重新运行,非常打断思路。nodemon 很好用,但有时我们想要一个更轻量、不依赖 npm 的方案。
今天我们用 OpenCode 在 4 分钟内写一个纯 Python 实现的命令行文件监控自动重启工具 watchrun,功能包括:
最终代码约 80 行,纯标准库,无任何第三方依赖。
打开终端,进入项目目录,我直接对 OpenCode 描述整个需求:
> 我对 OpenCode 说:
>
> "帮我用 Python 写一个命令行文件监控工具 watchrun.py,纯标准库实现,功能如下:
> 1. 接收一个要被执行的命令作为参数
> 2. 用 -d 指定监控目录,默认当前目录
> 3. 用 -e 指定要监控的文件后缀,如 .py .js,不指定则监控所有文件
> 4. 轮询检查文件 mtime 变化,发现有变化后自动杀掉旧进程并重启命令
> 5. 增加防抖机制:文件停止变动 2 秒后才重启,避免频繁重启
> 6. 终端输出加颜色,绿色表示启动、黄色表示重启、红色表示退出
> 7. 支持 Ctrl+C 优雅退出"
OpenCode 理解需求后,直接生成了完整的 watchrun.py:
#!/usr/bin/env python3
"""watchrun - 文件变更自动重启工具"""
import argparse
import os
import subprocess
import sys
import time
from pathlib import Path
GREEN = "\033[32m"
YELLOW = "\033[33m"
RED = "\033[31m"
RESET = "\033[0m"
def get_file_snapshot(root, extensions):
snapshot = {}
root = Path(root)
for f in root.rglob("*"):
if f.is_file():
if not extensions or f.suffix in extensions:
try:
snapshot[str(f)] = f.stat().st_mtime
except OSError:
pass
return snapshot
def run_command(cmd):
return subprocess.Popen(cmd, shell=True)
def main():
parser = argparse.ArgumentParser(description="文件变更自动重启工具")
parser.add_argument("command", help="要执行的命令")
parser.add_argument("-d", "--dir", default=".", help="监控目录 (默认: 当前)")
parser.add_argument("-e", "--ext", nargs="+", help="只监控指定后缀 (例: .py .js)")
parser.add_argument("-i", "--interval", type=float, default=1.0, help="轮询间隔秒 (默认: 1)")
parser.add_argument("--debounce", type=float, default=2.0, help="防抖时间秒 (默认: 2)")
args = parser.parse_args()
watch_dir = args.dir
extensions = set(args.ext) if args.ext else None
print(f"{GREEN}watchrun 启动 — 监控: {watch_dir}{RESET}")
print(f"{GREEN}命令: {args.command}{RESET}")
process = run_command(args.command)
last_snapshot = get_file_snapshot(watch_dir, extensions)
last_change = 0.0
try:
while True:
time.sleep(args.interval)
current_snapshot = get_file_snapshot(watch_dir, extensions)
changed = False
added = set(current_snapshot.keys()) - set(last_snapshot.keys())
removed = set(last_snapshot.keys()) - set(current_snapshot.keys())
for f in set(current_snapshot.keys()) & set(last_snapshot.keys()):
if current_snapshot[f] != last_snapshot[f]:
changed = True
break
if added or removed or changed:
last_change = time.time()
if last_change and (time.time() - last_change) >= args.debounce:
print(f"{YELLOW}[检测到文件变更,重启中...]{RESET}")
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process = run_command(args.command)
last_snapshot = get_file_snapshot(watch_dir, extensions)
last_change = 0.0
except KeyboardInterrupt:
print(f"\n{RED}退出...{RESET}")
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
sys.exit(0)
if __name__ == "__main__":
main()
几秒之内,一个完整的工具就出来了。 代码逻辑清晰:用 Path.rglob 扫描文件并记录 mtime 快照,轮询比对上一轮快照,发现变化后触发重启。防抖通过记录最后变更时间实现。
写好代码当然要跑一下。我创建一个简单的测试脚本:
# test_app.py - 一个假服务
import time
print("服务启动...")
while True:
time.sleep(1)
然后运行:
python watchrun.py "python test_app.py" -e .py
效果不错,绿色提示出现,服务成功运行。但我注意到一个问题:当监控目录下文件很多时(比如有 node_modules),扫描速度变慢。
> 我对 OpenCode 说:
>
> "增加一个 --exclude 参数,支持用 glob 模式排除某些目录,如 node_modules、__pycache__、.git 等默认排除"
OpenCode 在 get_file_snapshot 中增加了排除逻辑,使用 rglob 时跳过匹配到的目录。
轮询方式虽然简单,但在大目录下效率不够高。我问 OpenCode 能否使用操作系统级别的文件事件监听来提升效率:
> 我对 OpenCode 说:
>
> "能不能优先用 watchdog 库做事件监听,如果没装就 fallback 到轮询模式?"
OpenCode 的建议很务实:"watchdog 确实更高效,但会引入第三方依赖。当前项目定位是零依赖的轻量工具,轮询的 CPU 开销对于 1 秒间隔来说完全可忽略。建议保持纯标准库方案不变。"
我接受了这个建议——保持简单零依赖是正确的取舍。
工具完成后,实际使用非常方便。比如开发 Flask 应用时:
python watchrun.py "python app.py" -e .py -d src
修改 src/ 目录下任意 .py 文件,保存后等待 2 秒,服务自动重启:
watchrun 启动 — 监控: src 命令: python app.py 服务启动... [检测到文件变更,重启中...] 服务启动...
整个过程完全自动,无需手动操作。
#!/usr/bin/env python3
"""watchrun - 文件变更自动重启工具"""
import argparse
import os
import subprocess
import sys
import time
from pathlib import Path
GREEN = "\033[32m"
YELLOW = "\033[33m"
RED = "\033[31m"
RESET = "\033[0m"
def get_file_snapshot(root, extensions, exclude_dirs=None):
if exclude_dirs is None:
exclude_dirs = {"node_modules", "__pycache__", ".git", ".venv", "venv", ".tox"}
snapshot = {}
root = Path(root)
for f in root.rglob("*"):
parts = set(f.parts)
if parts & exclude_dirs:
continue
if f.is_file():
if not extensions or f.suffix in extensions:
try:
snapshot[str(f)] = f.stat().st_mtime
except OSError:
pass
return snapshot
def run_command(cmd):
return subprocess.Popen(cmd, shell=True)
def main():
parser = argparse.ArgumentParser(description="文件变更自动重启工具")
parser.add_argument("command", help="要执行的命令")
parser.add_argument("-d", "--dir", default=".", help="监控目录 (默认: 当前)")
parser.add_argument("-e", "--ext", nargs="+", help="只监控指定后缀 (例: .py .js)")
parser.add_argument("-i", "--interval", type=float, default=1.0,
help="轮询间隔秒 (默认: 1)")
parser.add_argument("--debounce", type=float, default=2.0,
help="防抖时间秒 (默认: 2)")
parser.add_argument("--exclude", nargs="+",
help="额外排除的目录名")
args = parser.parse_args()
watch_dir = args.dir
extensions = set(args.ext) if args.ext else None
exclude_dirs = {"node_modules", "__pycache__", ".git", ".venv", "venv", ".tox"}
if args.exclude:
exclude_dirs |= set(args.exclude)
print(f"{GREEN}watchrun 启动 — 监控: {watch_dir}{RESET}")
if extensions:
print(f"{GREEN}文件后缀: {', '.join(extensions)}{RESET}")
print(f"{GREEN}命令: {args.command}{RESET}")
process = run_command(args.command)
last_snapshot = get_file_snapshot(watch_dir, extensions, exclude_dirs)
last_change = 0.0
try:
while True:
time.sleep(args.interval)
current_snapshot = get_file_snapshot(watch_dir, extensions, exclude_dirs)
changed = False
added = set(current_snapshot.keys()) - set(last_snapshot.keys())
removed = set(last_snapshot.keys()) - set(current_snapshot.keys())
for f in set(current_snapshot.keys()) & set(last_snapshot.keys()):
if current_snapshot[f] != last_snapshot[f]:
changed = True
break
if added or removed or changed:
last_change = time.time()
if last_change and (time.time() - last_change) >= args.debounce:
print(f"{YELLOW}[检测到文件变更,重启中...]{RESET}")
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process = run_command(args.command)
last_snapshot = get_file_snapshot(watch_dir, extensions, exclude_dirs)
last_change = 0.0
except KeyboardInterrupt:
print(f"\n{RED}退出...{RESET}")
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
sys.exit(0)
if __name__ == "__main__":
main()
整个工具从想法到可运行,耗时不到 4 分钟。OpenCode 省去的不只是打字的时间,更重要的是省去了"回忆标准库 API"的心智开销——pathlib.Path.rglob 怎么写、argparse 怎么配、subprocess.Popen 的参数是什么,这些细节通通不用查文档。
与传统开发方式相比,使用 OpenCode 的核心区别在于:
80 行 Python 代码,一个实用的开发辅助工具,这就是 OpenCode 带来的效率。