春节前的一段时间,重新拾起近一年没动过的ForeverFantasy。
虽然一年内没什么更新,但是我却一直都在用,基本上只限于将用Vim写好的Markdown格式的文档转换成HTML。
重写了相当一部分代码,较大程度地改变了界面布局,突然发现经过一年的沉淀,对wxPython的理解增进了不少,开发起来比起去年这个时候清楚了很多。

这些天来一直坚持着每天或多或少的做一些,如果说有什么主要的进展的话,那就是界面的重构,以及昨天实现了调用Vim编辑文档并回收文档内容的功能。
ForeverFantasy和Vim协同的一个最大的问题就是如何判断Vim已经退出。Python调用外部程序的方法有很多,比如传统的commands模块、os.system()等,subprocess是致力于替代这些旧有的方式的一个模块,它的一个特点是可以在启动一个外部程序作为子进程后还能监控这个进程的运行状态。这为ForeverFantasy在Vim退出后回收文档内容提供了更简捷的途径。
下面的代码可以说明如何使用subprocess运行外部程序并监控运行状态:
PYTHON:
-
import subprocess
-
process = subprocess.Popen('gvim', shell=True)
-
status = process.poll()
-
if 0 == status:
-
print 'The external program exited.'
-
if status is None:
-
print 'The external program is still running.'
理论上,可以拿实例process的poll()方法监视进程的运行状态,而且这一点在Windows上也确实可以做到,但是到了Linux下,诡异的事出现了,即使刚刚打开gvim,poll()方法也会马上返回一个0,同时process.pid的值总是和实际在运行的那个gvim进程的pid的值差3,而且在虚拟终端中输入gvim命令也不会阻塞终端,就像别的命令加上“&”符号的效果一样。尝试用strace跟踪gvim的运行,试图找到问题原因,无果。我猜想可能/usr/bin/gvim是一个跳板,它启动后会启动一个新的gvim进程。总之,这个方法在Linux下是行不通的。
因此我觉得只能另寻出路了,既然不能监控gvim进程,那就监控gvim进程所编辑的文件,只要这个文件不被任何进程占用,就可以判定gvim已退出。这一点,在Linux下易如反掌,不用说,lsof当仁不让。
用commands.getstatusoutput('lsof file.txt')测试发现,如果文件file.txt被某进程占用,则返回的状态值为0,反之,返回256。
最终,我在程序中使用了两种判断方式,在Windows平台使用subprocess跟踪gvim.exe进程,而在Linux及Unix平台使用lsof检查文档占用情况。
剩下的就是进行这个判断的时间问题了。
很显然,如果在子进程被启动后马上使用while循环不停的检查,一来必须使用多线程,二来系统资源占用也会很高。这时就需要利用wxPython的事件机制了,当ForeverFantasy启动Gvim时,主窗口失去焦点,而当Gvim退出时,ForeverFantasy又会获得焦点,只要能在ForeverFantasy窗口获得焦点时做一次检查即可。不过,在选择最合适的事件的问题上,又是一波三折。
在wxPython的API文档中没有找到事件列表,倒是在Wiki中找到了。顾名思义,觉得wx.EVT_SET_FOCUS比较靠谱,但试用失败,看API中关于FocusEvent的说明,这个事件适用于窗口控件;然后又试了wx.EVT_CHILD_FOCUS,只有在窗口包含的控件中有获得焦点的情况才会触发;最后才发现wx.EVT_ACTIVATE,这个事件会在窗口失去焦点和获得焦点时各触发一次,使用GetActive()方法可以判断是获得焦点还是失去焦点。
完成与Vim的协同使ForeverFantasy在我手上由原来单纯的格式转换工具进化为基本可用的文档编辑器,就算是Milestone 2吧。
此外,还有一些小的经验:
1. 调用非环境变量下的程序,即命令中必须带程序所在的路径时,应当将程序所在目录的完整路径以自然字符串的形式传递给subprocess.Popen类的构造方法的cwd参数,即如下所示:
PYTHON:
-
process = subprocess.Popen('gvim.exe', cwd=r'C:\program files\vim\vim72', shell=True)
这样可以有效避免路径中的空格和特殊字符对命令执行的影响。
2. Vim编辑一个文档时,实际操作的是一个临时文件,而不是原文件,这个临时文件与原文件同路径,名称为在原文件名的基础上,前面加一个句点,后面加后缀“.swp”。应该用lsof监控这个临时文件,才可以判断出编辑该文档的Vim进程的运行状态。由于对于不存在的文件使用lsof命令的返回值也是256,故可以同时判断临时文件和原文件的占用情况,这样就为对其它编辑器的支持奠定了基础。
饭否以这样的方式告别也算是厚道了。
当初选择饭否本就有所担心,最终给我临门一脚的是可以用短信发消息,但是该来的终究还是来了。
当舵手和影帝们耀武观兵的时候,草民我还盘算着等饭否上线了把数据都导出来,结果泥牛入海。
没想到竟然以这样的方式得以将数据导出。


关于自动补全,最初用的是SuperTab,那个时候Vim的自动补全插件寥寥无几,也就SuperTab比较有名。不过实际使用过程中这个插件给我的体验不是很好,原因是补全的准确度不高。
后来出了一个新插件,AutoComplPop,功能和使用都很简单。但是很快我就又用回SuperTab,原因是AutoComplPop在输入的同时实时地查询匹配的关键词,导致输入极不流畅,效率很低。
一直就这么凑合着用着SuperTab,随着这个插件版本的更新,旧功能不断地完善,新功能也接二连三地引入,SuperTab在匹配关键词的准确度上有了一些改善,但是新的问题又出现了。SuperTab后来加入一个新功能,对于程序源文件,可以在其引入的文件以及API文件中匹配关键词。比如假设我当前正在编辑a.php,在a.php中有include 'b.php';这样的语句,当我输入array并按下Tab键时,SuperTab不但会在当前文件中查询所有匹配项,还会到b.php中查询,如果配置过vim、指定一个包含了php的API的文件,则SuperTab还会自动从这个文件中查询匹配项。按理说这个功能的理念很好,但问题就在于SuperTab做的是实时查询,如果源文件中包含的文件较多,各个文件又较大,问题就显而易见了。我不得不在写程序时小心地使用Tab键,否则有时就会出现按一下Tab键然后等着Vim在那狂搜的情况。
前两天发现了NeoComplCache,光看名字就让我有点儿兴奋,一般使用缓存的速度都很快。这个插件会在Vim打开文件的时候对上下文作一个索引,并把索引结果保存到缓存中。同时,文件更改的内容会在保存的时候被索引。此外,NeoComplCache支持多种关键词索引模式,例如它会判断当前路径下的文件或目录的名字是否匹配补全条件,也可以从缓存的程序语言API中匹配补全条件。到此为止,它就解决了SuperTab和AutoComplPop共同的效率问题,并具备它们各自的长处。看了一遍文档,发现这个插件的功能比较细致,大概有以下一些特点:
1、使用缓存,自动补全时效率高;
2、生成的关键词列表准确;
3、支持下划线分割的关键词,如apple_boy_cat,就可以只输入a_b_c,然后补全;
4、支持驼峰格式匹配关键词,如AppleBoyCat,就可以只输入ABC,然后补全;
5、既可以像AutoComplPop那样在Vim中输入的同时自动弹出补全列表,又可以自定义快捷键手动触发;
6、支持从文件名和目录名中匹配补全条件;
7、对于程序源文件,支持从语言API中匹配补全条件;
NeoComplCache的缺点是文档不全,虽然从只言片语中发现它还支持Snippet,但从文档中没有找到足够的有用信息。加之一直用SnipMate感觉不错,所以目前还是用它来实现snippet功能。
这就有个搭配问题:虽然NeoComplCache不存在补全时的效率问题,但我仍然打算只在需要补全时才用快捷键触发此功能,最主要的原因是我既希望用Tab键触发SnipMate的代码块补全功能,又希望修SuperTab那样用Tab选择补全列表中的选项。也就是要达到只用Tab键就可以完成打开自动补全列表、补全列表选项选择和SnipMate代码块替换的效果。但是,如果将Tab映射到触发自动补全,则补全列表选择和SnipMate均无法使用Tab,反之亦然。
所以我想如果能让NeoComplCache、SuperTab、SnipMate和谐共存,那问题就解决了,几经摸索,终于找到了办法:
1、设置NeoComplCache不自动弹出补全列表,即在vimrc中加入:
let g:NeoComplCache_DisableAutoComplete = 1
2、由于NeoComplCache在手工模式下使用快捷键组合<C-X><C-U>打开补全列表,故设置SuperTab的默认补全操作为<C-X><C-U>,即在vimrc中加入:
let g:SuperTabDefaultCompletionType = '<C-X><C-U>'
这样,NeoComplCache只负责补全关键词缓存的生成,SuperTab控制Tab键的行为并在需要触发补全操作时打开补全列表、进而在列表中的选项间移动焦点,而当光标前的关键词是snippet时,SnipMate会被优先调用并完成代码块的替换。
就在写这篇文章的时候,我突然觉得NeoComplCache自动弹出补全列表+SnipMate的方式也挺好,只是这样就不能用Tab键选择列表中的选项了。
最初将脚本的文件编码和coding行都设定为UTF-8,在windows下执行时,中文无法保存,报编码错误。将上述两个编码改为GBK后,保存正常,但查询时报错。
Traceback内容如下:
Traceback (most recent call last):
File "test.py", line 36, in <module>
for obj in session.query(User):
File "C:\Python26\lib\site-packages\sqlalchemy-0.6beta1-py2.6.egg\sqlalchemy\orm\query.py", line 1411, in instances
rows = [process[0](row, None) for row in fetch]
File "C:\Python26\lib\site-packages\sqlalchemy-0.6beta1-py2.6.egg\sqlalchemy\orm\mapper.py", line 1788, in _instance
populate_state(state, dict_, row, isnew, only_load_props)
File "C:\Python26\lib\site-packages\sqlalchemy-0.6beta1-py2.6.egg\sqlalchemy\orm\mapper.py", line 1677, in populate_state
populator(state, dict_, row, isnew=isnew, **flags)
File "C:\Python26\lib\site-packages\sqlalchemy-0.6beta1-py2.6.egg\sqlalchemy\orm\strategies.py", line 118, in new_execute
dict_[key] = row[col]
File "C:\Python26\lib\site-packages\sqlalchemy-0.6beta1-py2.6.egg\sqlalchemy\engine\base.py", line 1634, in __getitem__
return self.__colfuncs[key][0](self.__row)
File "C:\Python26\lib\site-packages\sqlalchemy-0.6beta1-py2.6.egg\sqlalchemy\engine\base.py", line 1716, in getcol
return processor(row[index])
File "C:\Python26\lib\site-packages\sqlalchemy-0.6beta1-py2.6.egg\sqlalchemy\types.py", line 568, in process
return decoder(value)[0]
File "C:\Python26\lib\encodings\utf_8.py", line 16, in decode
return codecs.utf_8_decode(input, errors, True)
UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-1: ordinal not in range(128)
环境为:
OS:Windows XP简体中文版
DB:SQL Server 2008 Express简体中文版
DB模块:pyodbc
脚本文件编码:GBK
脚本coding行:GBK
脚本内容:
PYTHON:
-
#!/usr/bin/python
-
# -*- encoding: gbk -*-
-
-
from sqlalchemy import create_engine
-
from sqlalchemy.ext.declarative import declarative_base
-
from sqlalchemy.orm import sessionmaker
-
from sqlalchemy import Column, Integer, String, Text, ForeignKey, Numeric, Unicode
-
-
Base = declarative_base()
-
-
class User(Base):
-
"""User class"""
-
-
__tablename__ = 'users'
-
-
id = Column(Numeric(22,0), primary_key=True)
-
name = Column(Unicode(128), nullable=False, unique=True)
-
-
def __init__(self, id, name):
-
self.id = id
-
self.name = name
-
-
if __name__ == '__main__':
-
db_engine = create_engine('mssql://sa:password@localhost/mydatabase', echo=True)
-
Session = sessionmaker(bind=db_engine)
-
session = Session()
-
-
Base.metadata.drop_all(db_engine)
-
Base.metadata.create_all(db_engine)
-
-
jim = User(1, '中文')
-
session.add(jim)
-
session.commit()
-
-
'''
-
for obj in session.query(User):
-
print obj.name
-
'''
上面的脚本执行后,数据得以正常保存,在数据库中的查询结果也正常,没有乱码。但是,当把从drop_all()到commit()行注释掉,取消for循环前后的多行字符串起止符后,即运行查询时,抛出上面的Traceback。
Google了很长时间,没有找到有用的东西。CPyUG更没指望。
回溯Traceback,打开sqlalchemy的types.py,UnicodeEncodeError的抛出点在String类的result_processor()方法:
PYTHON:
-
def result_processor(self, dialect, coltype):
-
wants_unicode = self.convert_unicode or dialect.convert_unicode
-
needs_convert = wants_unicode and \
-
(not dialect.returns_unicode_strings or
-
self.convert_unicode == 'force')
-
-
if needs_convert:
-
# note we *assume* that we do not have a unicode object
-
# here, instead of an expensive isinstance() check.
-
decoder = codecs.getdecoder(dialect.encoding)
-
def process(value):
-
if value is not None:
-
# decoder returns a tuple: (value, len)
-
return decoder(value)[0]
-
else:
-
return value
-
return process
-
else:
-
return None
这个方法就是根据数据库方言dialect和字段类型coltype返回一个字符串的解码函数。若在if语句上面将needs_convert置为False,即不对该字段使用解码器,则再执行上面的脚本时,查询正常。
由于前面create_engine()函数的encoding参数缺省为UTF-8,故dialect.encoding的值为“UTF-8”,故if语句中decoder实际引用的是codecs.utf_8_decode()。也就是说,result_processor()方法在实际执行过程中返回的是一个封装了utf_8_decode()函数的函数。即,UnicodeEncodeError是在对从数据库中查询出来的中文字符串进行UTF-8解码时抛出的。
对传入process()函数的值作isinstance(value,unicode)判断,显示为True,表明从数据库中查询出来的中文本身就是unicode字节码,当对它再进行UTF-8解码时,就抛出了UnicodeEncodeError的错误。为验证以上判断,做如下实验:
PYTHON:
-
>>>t = '中文'
-
>>>u = u'中文'
-
>>>isinstance(t, str)
-
True
-
>>>isinstance(t, unicode)
-
False
-
>>>isinstance(u, str)
-
False
-
>>>isinstance(u, unicode)
-
True
-
>>>x = t.decode('utf-8')
-
>>>x
-
u'\u4e2d\u6587'
-
>>>isinstance(x, unicode)
-
True
-
>>>x == u
-
True
-
>>> import codecs
-
>>> dc = codecs.getdecoder('utf-8')
-
>>> dc(u)
-
Traceback (most recent call last):
-
File "<input>", line 1, in <module>
-
File "/usr/lib/python2.6/encodings/utf_8.py", line 16, in decode
-
return codecs.utf_8_decode(input, errors, True)
-
UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-1: ordinal not in range(128)
得证。
在Python中,字符串类型str和unicode类型是两种不同的数据类型,str类型的数据可以通过指定正确的编码来转换成unicode类型,对unicode类型的数据作重复的解码操作就会抛出类似上面的错误。
实事上,若将name字段声明为String类,则保存和查询操作均无问题。但由于我需要sqlalchemy建表时将相应字段的类型设为nvarchar,故必须使用Unicode类声明该列。
那有没有办法使result_processor()方法不返回一个对字段值作重复解码的函数呢?
返回result_processor()方法,self.convert_unicode对于Unicode类是True,dialect.convert_unicode由create_engine()函数的convert_unicode参数控制,缺省为False,故needs_convert变量为True,无法更改;dialect.returns_unicode_strings是由sqlalchemy.engine模块default.py中的DefaultDialect类的_check_unicode_returns()方法返回的,该方法内容为:
PYTHON:
-
def _check_unicode_returns(self, connection):
-
cursor = connection.connection.cursor()
-
cursor.execute(
-
str(
-
expression.select(
-
[expression.cast(
-
expression.literal_column("'test unicode returns'"),sqltypes.VARCHAR(60))
-
]).compile(dialect=self)
-
)
-
)
-
-
row = cursor.fetchone()
-
result = isinstance(row[0], unicode)
-
cursor.close()
-
return result
此方法的功能为生成一条SQL语句,在数据库中执行后,判断返回的值是否为unicode类型。由于SQL Server是ASCII编码,故此方法返回False。因此,dialect.returns_unicode_strings的值为False。最终,needs_convert只能为True。我觉得这是sqlalchemy的一个Bug。
在此条件下,目前尚未找到较好的解决办法,只能使用硬编码强制置result_processor()方法中的needs_convert变量为False。
2010-02-25 更新:
谢谢KL童鞋指出问题原因和解决办法,使问题得以完美解决。
1、由于Python在载入site模块时会删除setdefaultencoding()函数,故不能以在脚本开头调用此函数的方式指定默认编码;sitecustomize.py是一个python会自动导入的模块,故应当使用这个文件指定默认编码;
2、我这里需要使用utf-8作默认编码器,sitecustomize.py的内容如下:
PYTHON:
-
#!/usr/bin/python
-
# -*- coding: gbk -*-
-
import sys
-
sys.setdefaultencoding('utf-8')
3、将sitecustomize.py保存到python安装目录下的Lib\site-packages目录中;
另外,在此处发现了跟本问题相关的资料,辅助治疗,效果更佳。
年末的这段时间一直在给客户导数据,数据量比较大,少则几万条,多则几十万条。由于客户化的功能较多,公司标准的数据导入工具无法实现。
最初以PHP脚本的形式完成了一些导入工作,但是问题也很明显:一是无法实现浏览器中实时显示导入状态,试遍了ob_flush()、flush()、str_pad()等所有能找到的办法,对IE6都毫无效果;二是由于一些不标准的开发过程导致了事务提交过程的混乱,以至于经常出现事务无法回滚的问题,对于数据的健全,我心里没底;三是由于数据导入毫费时间长,浏览器经常出现报“该页无法显示”的问题,即使使用了set_time_limit(0)。
和其它同事交换了一下意见后,又以公司产品所支持的定时任务的形式实现了一些数据的导入,效果明显好了不少。然而虽然规避了上述第一和第三个问题,一来第二个问题无法解决,二来出现了一个新的问题:长时间执行的后台定时任务总是会不定时地退出,而日志中却找不到任何线索。
也尝试过以命令行脚本的形式解决问题,但是在我的开发环境中执行得好好的PHP脚本,到了用户那里却是一执行就报环境相关的错误。此外还有一个致命的问题,就是公司的产品是加过密的,因此在命令行执行脚本时,所有依赖都无法解析。
还是本朝太祖孝武皇帝说得好啊,自己动手,丰衣足食。于是着手用Python从零开始写数据导入脚本,上面的问题都不存在了,不过客户服务器上没有Python环境,只能先打包成可独立执行的应用程序才行。
粗略地了解了一下几个比较流行的Python程序打包工具,py2exe使用最麻烦,需要手工编写配置文件,而且只能在windows上打包windows的应用程序;pyinstaller要先自动生成配置文件,再打包;cx_Freeze支持直接打包,且是跨平台的,因此选用了它。当然,对于比较复杂的Python程序,还是应该通过编写配置文件的方式打包,pyinstaller和cx_Freeze也支持这个功能。
cx_Freeze打包Python程序的命令基本格式如下:
PYTHON:
-
cxfreeze main.py --target-dir appdir
它表示把脚本main.py或以main.py为程序入口的程序打包并导出到当前路径中名为appdir的目录中。
对于Windows下的GUI应用程序,以上面的命令导出后,运行时会弹出cmd命令行的黑窗口,须加上如下命令中的参数:
PYTHON:
-
cxfreeze main.py --target-dir appdir --base-name=win32gui