在前一篇博客文章 《使用 Python 编写脚本并发布》 中,我介绍了如何使用 Python 进行脚本编程,说实话这是我在尝试 Python 进行网站和网络编程之后首次使用 Python 进行脚本编程,前面也说过之前虽然使用 Bash 构建过一些脚本,但是由于我对 Bash 不熟练,对它的使用都仅限于最基础的命令行操作,仅仅是比 alias 别名操作稍微简单一点。上次介绍的脚本是如何添加命令行参数以及将现有的操作流程用一个脚本简单化,这一次介绍的脚本是一个非常实用而且经过优化的文件变动事件监视脚本。
P1 Python 脚本:文件变动检测
在廖雪峰 Python 教程实战部分的 Day 13 - 提升开发效率 中,他给我们介绍了一种用于提升开发效率的方法:
- 首先执行我们需要的命令
- 监听当前目录,并判断变动文件的后缀名,若后缀名为 .py,则触发回调函数
- 回调函数触发后,自动重新启动命令
流程很清楚,实现起来也很简答,廖雪峰利用 Python 的 subprocess 和第三方库 watchdog 分别实现了重启命令和监听当前目录的文件变动情况。大概 70 余行代码就能完成这样一个简单且实用的脚本。
在我编程的过程中,经常需要用到这样一个监控文件变动并自动重新执行预设命令的操作,比如我在编写 SSPYMGR 这个网站程序时经常要用到文件改动后自动重启服务器的操作,或者我经常需要在改动某些文件后自动上传到虚拟机上。当我有这些需求时,我之前的做法就是将上面廖雪峰介绍的脚本复制到我要监视的文件夹中,然后直接修改脚本里面的命令参数,这样做很直接,但是很繁琐。
我要做的是:将上面的简单脚本进行优化,使得可以通过命令行参数对脚本的行为进行设置。主要的优化目标有:
- 可以预设命令,并且该命令可以带参数
- 可以设置监听的目录,并且设置是否递归监听子目录
- 可以设置监听的文件后缀名,并设置可以排除在监听范围内的文件名
- 增加保存参数功能,并且能够读取保存的配置文件
P2 优化脚本
为了实现上面这些目标,就像我们在上一篇博客那样,用 argparse 库来对复杂的命令行参数进行解析,这一次我们换一种代码的组织方式,将命令行参数的解析和配置文件封装到类中,然后通过实例化类对象解析参数,然后将配置写入到字典中,程序执行流程以指定的配置文件为主:
若指定了要读取的配置文件,则将配置文件中的内容作为配置,忽略掉其他选项。指定配置文件主要可以简化命令行的参数输入过程。若没有指定读取的配置文件,则以命令行中其他的选项为配置。
在 monitor.py 这个脚本中我将配置和命令行参数读取封装到类 Configuration
中:
class Configuration(object):
_DEFAULT_LOC = _CONFIG_DIR / "monitor_default.json"
def __init__(self):
self.config = {}
self._addArgs()
def readConfig(self, file: Path):
pass
def _addArgs(self):
pass
def parseArgs(self):
pass
监听目录
接下来就要用到第三方库 watchdog 来监听指定的目录及指定事件触发时的操作了。事件处理器要用到 watchdog.events.FileSystemEventHandler
,我们用继承的方式处理事件:
from watchdog.events import FileSystemEventHandler
class MyFileSystemEventHander(FileSystemEventHandler):
def __init__(self, fn, config: Configuration):
super(MyFileSystemEventHander, self).__init__()
self.restart = fn
self.config = config
self.last = time.time()
构造事件处理器时需要传入回调函数和配置对象,接下来定义事件处理函数,这里会监听目标文件夹中所有的文件事件 on_any_event
,但是该事件会在保存文件时触发两次,因此需要对它做一个防抖处理,防抖处理就是判断两次事件触发的时间间隔是否超过预设值,若两次事件时间间隔过短,则忽略第二次事件。
以下时事件处理的代码:
class MyFileSystemEventHander(FileSystemEventHandler):
def on_any_event(self, event):
# for debounce
cur = time.time()
if cur - self.last < 0.25:
return
self.last = cur
ext_able = False
src = Path(event.src_path)
if src.name not in self.config["exclude"]:
for ext in self.config["mon_ext"]:
if src.suffix == ext:
ext_able = True
break
if ext_able:
logger.info('File changed: {}'.format(src))
self.restart()
上面的防抖处理时以第一次事件为准,忽略掉之后一段时间内的其它事件,这样做更方便。
还有另一种复杂但更合理的处理方式,即事件触发时不立即调用处理函数,延迟一段时间,在该段时间内若有其他事件发生,则以新事件为准,重新计算延迟时间,超过时间后再执行事件处理的代码。
第二种处理方式更合理。打个比方,我在很短的时间内先后保存了两个不同的文件 A 和 B,用第一种方式,程序重启后只会重新加载 A 文件而 B 文件的改动很可能被忽略掉了;而用第二种方式 A 文件改动后程序并不会立即重新加载,而 B 文件的改动会被监听到,最终就是在延迟一段时间后程序会重新加载 A 和 B 这两个文件。
自动重启程序
自动重启程序时依靠 subprocess.Popen
对象实现的,启动的时候实例化一个 Popen 对象,停止程序时调用它的 kill()
方法;重启就是先 kill
再重新实例化。这个过程用 NewProcess
类进行封装:
import sys
from watchdog.observers import Observer
import subprocess
class NewProcess(object):
def __init__(self, config: dict):
self.process = None
self.config = config
self.command = self.config["cmd_args"][:]
self.command[0:0] = self.config["cmd"]
self.args = ' '.join(self.command)
然后还需要用到 watchdog.observers.Observer
,用来监听目录,并且通知处理器进行处理:
class NewProcess(object):
def start_watch(self):
observer = Observer()
observer.schedule(MyFileSystemEventHander(self._restart, self.config),
path=self.config["mon_dir"],
recursive=self.config["recursive"]
)
observer.start()
logger.info('Watching directory: {}'.format(self.config["mon_dir"]))
self._start()
try:
while True:
time.sleep(0.5)
except KeyboardInterrupt:
observer.stop()
observer.join()
脚本就完成了。可以在命令行中尝试一下,输入 bf_monitor -c echo -a test
可以看到类似的输出:
它还有些缺陷,不能在 -a 后面添加的参数里带有 - 前缀:bf_monitor -c echo -a -test
是不允许的:
为了解决这个问题,只有在 -c 后面将这些命令用引号包裹起来,bf_monitor -c "python -V"
:
关于 watchdog 的详细使用或者 API,请参阅其 官方文档.
P3 python 国际化 i18n
到目前为止,我已经用 Python 做了两个脚本:bf_gitrepo 和 bf_monitor,并且我给他们都加上了命令行帮助信息,但是它们的帮助信息都是英文,我们要把这些信息翻译成中文。翻译工作主要依靠 Python 的 gettext
模块和第三方的 pybabel
模块。
事实上,国际化只要尝试一遍流程之后就很简单了,我第一次使用 pybabel 时,大部分时间都是在提取可翻译文本上,之后做 monitor.py 脚本的翻译时就轻车熟路,完成的很快,只在翻译上花了点时间。
在 brifuture-facilities
中,我将 gettext 模块简单的封装了一下,程序会在脚本的同级目录下寻找 locale 文件夹中的 .mo 文件,然后替换脚本中的文本:
LANGUAGE_DIR = (Path(__file__).parent / "locale").resolve()
import gettext
def initGetText(domain="myfacilities") -> gettext.gettext:
gettext.bindtextdomain(domain, LANGUAGE_DIR)
gettext.textdomain(domain)
gettext.find(domain, "locale", languages=["zh_CN", "en_US"])
return gettext.gettext
一般会将 gettext.gettext
以其他的名称导入到 Python 程序中,如 from gettext import gettext as _
,由于之前我习惯用 Qt 翻译方法 tr,所以我将 gettext.gettext
用别名 tr 代替。在程序中要替换文本的位置用 tr 方法包裹起来:
parser.add_argument("-d", "--directory", help=tr("The directory to monitor, . by default."))
然后我们需要配置 babel,要读取的只有 python 文件(如果你要读取其他文件,可以看看 [文档](http://babel.pocoo.org/en/latest/):
# file: babel.cfg
# Extraction from Python source files
[python: **.py]
keywrods = tr
文本查找
接下来使用 pybabel 程序进行文本查找,我们只用查找 monitor.py 文件:
# pybabel extract -F ./babel.cfg -o ./bffacilities/locale/{}.pot -k tr ./bffacilities/{}.py
pybabel extract -F ./babel.cfg -o ./bffacilities/locale/monitor.pot -k tr ./bffacilities/monitor.py
尽管前面的配置文件中指定了关键字为 tr,但我在使用中发现调用 extract
子命令时最好还是加上选项 -k tr
,保证能够提取出文本。
查看 bffacilities/locale 目录下,应该有 monitor.pot 文件,里面有很多的 msgid、msgstr。这个文件就保存了所有要翻译的文本。当程序中的文本更新后,重新调用上面的命令再次提取文本即可。
文本翻译
然后我们要对提取出来的文本进行翻译,如果是初次翻译要使用 init
子命令,但若是更新翻译就不是用 init
子命令而是用 update
子命令了:
# pybabel init -i ./bffacilities/locale/{}.pot -d ./bffacilities/locale/ -l zh_CN -D {}
pybabel init -i ./bffacilities/locale/monitor.pot -d ./bffacilities/locale/ -l zh_CN -D monitor
# pybabel update -i ./bffacilities/locale/{}.pot -d ./bffacilities/locale/ -l zh_CN -D {}
pybabel update -i ./bffacilities/locale/monitor.pot -d ./bffacilities/locale/ -l zh_CN -D monitor
之后我们就可以开始翻译了,在 ./bffacilities/locale/zh_CN/LC_MESSAGES/
目录下找到 monitor.po
文件,用编辑器打开,或者用 Poedit 打开,在 msgid 对应的 msgstr 下面填入文本即可。注意有些语句的 msgid 可能会跨多行,不用管它直接翻译就行。
翻译完成后对其进行打包:
# pybabel compile -d ./locale/ -D {}
pybabel compile -d ./bffacilities/locale/ -D monitor
查看翻译效果
制作完翻译文件后,来看看脚本帮助信息是不是输出中文了,先检查一下 locale 的输出:
查看 bf_monitor 的帮助信息:
修改 locale,LANG=en_US.UTF-8 && LANGUAGE=en_US
,locale 输出变为:
再看看 bf_monitor 的帮助信息:
修改 setup.py
最后我们要将写好的程序打包,为了防止在打包过程中丢失翻译文件,我们要将 setup 参数中的 zip_safe 改为 false: zip_safe=False
,
然后在 setup.py 的同级目录下添加 MANIFEST.in
文件,内容如下:
recursive-include bffacilities/locale *
global-exclude *.pyc
最后上传到 pypi 上面即可通过 pip 下载安装。
P4 小结
之前使用 Nodejs 时,我用 Node 编写过一个文件变动检测的脚本,但是现在我找不到之前的那篇博客了,文件变动检测的 Python 脚本和 Node.JS 脚本原理都是一样的,都是通过监听文件事件,然后执行回调函数。
另外通过这次的翻译过程我掌握了如何国际化 Python 程序,之前我做 Qt 程序时对 Qt 的翻译流程比较清楚,转用 Python 程序后发现其实国际化的流程都很类似,在文件中查找调用翻译函数,提取之后用软件或编辑器进行翻译。最后转换成程序可以直接读取的格式(可能这样做能够提高程序的效率吧)。
这段程序的代码可以在 github 上找到,你也可以看到整个项目的源代码。如果你觉得这篇文章对你有所帮助或者你认为这篇文章还不错,就给我点个赞吧,感谢你的支持。
参考
python的国际化gettext模块
Flask-Babel 简介
The Invent with Python Blog
http://babel.pocoo.org/en/latest/messages.html