最近发现如果使用最新的 2.7.4 版本的 psycopg2,import psycopg2
时会出现警告:
UserWarning: The psycopg2 wheel package will be renamed from release 2.8; in order to keep installing from binary please use "pip install psycopg2-binary" instead. For details see: http://initd.org/psycopg/docs/install.html#binary-install-from-pypi
大致就是因为老版本中自带的预编译 wheel 二进制可能会造成崩溃问题,官方决定 psycopg2 从 2.8 版本开始将不再包含预编译二进制,必须通过编译安装。为此还作出了两个调整:
- 2.7.4 ~ 2.8 之间为过渡期,使用 psycopg2 的预编译二进制时会生成警告
- 新增了 psycopg2-binary 包,除了仍旧提供预编译二进制外,与 psycopg2 完全一致(即与老版本行为一致)。
找了一下相关的讨论,感觉比较赞同这个 Issue 中的反对观点:
- 推荐 psycopg2-binary 实际上是个换汤不换药的解决方案,什么问题都没有解决;
- 虽然 psycopg2-binary 的包名没变(还是
import psycopg2
),但一些第三方库可能因此产生依赖问题;
- 由于目前 Python 的包管理机制,psycopg2 和 psycopg2-binary 虽然可以共存,但删的时候会同时删掉,很滑稽。
官方在说明中推荐开发用 psycopg2-binary、生产用 psycopg2,我总觉得有些坏味道。(同时也懒 :-P)
如果对此无所谓,可以在导入时自行捕获警告。(如果是通过 SQLAlchemy 间接导入,则在 create_engine
时捕获即可。)
import warnings
with warnings.catch_warnings(record=True):
import psycopg2
个人觉得比较 Sane 的方法可能是在 pip install
时使用 --no-binary <NAME>
来跳过预编译二进制的使用。如果你和我一样用的是 Pipenv,则需要用环境变量来控制 pip 的行为:
$ PIP_NO_BINARY=psycopg2 pipenv install psycopg2
但愿官方能把 psycopg2-binary 取消掉。
以上,默默水了一篇。
新项目中第一次开始作死使用 Python 3,在测试中遇到一件奇怪的事情。
系统中有一个邮件发送模块,直接在命令行中手动跑 Worker 的话邮件可以成功发送,而一旦用 supervisor 运行则无法发送邮件。日志中显示在邮件发送库中抛出了如下异常:
UnicodeEncodeError: 'utf-8' codec can't encode character '\udce6' in position 0: surrogates not allowed
Surrogate
所以,日志里这个 surrogate 是个啥?
Surrogate 是 Unicode 中位于 BMP 外的一组不会有对应字符的码位,Python 3 中使用这些码位来「代表」无法编码的字节。
如果你明白「锟斤拷」是怎么来的,那么就一定能理解 Python 的 replace
编解码错误处理机制:无法解码的字节会被替换成 U+FFFD
,无法编码的码位会被替换成 ?
。这样做有一个明显的缺点就是不可逆。Python 3 中新增的 surrogateescape
则是一种可逆的错误处理机制,利用 Surrogate 码位保存无法解码的字节,编码时则将其还原为对应的原始字节。
例子中的报错正是因为 0xE6
无法使用 ascii
解码,而被解为了 U+DCE6
。
'\udce6'.encode('ascii', 'surrogateescape')
>>> b'\xe6'
b'\xe6'.decode('ascii', 'surrogateescape')
>>> '\udce6'
知道了原因,用这种方法解码异常信息中的完整字符串就找到了罪魁祸首:邮件发送者的配置。
Python 3 的环境变量与编码
邮件发送者是使用 显示名称 <邮箱地址>
的格式在环境变量中设置的,显示名称部分包含了汉字。
Python 3 中,使用 os.getenv
或者 os.environ
获得到的环境变量的值都是 str
类型。在类 Unix 系统中,解码过程是使用 sys.getfilesystemencoding()
和 'surrogateescape'
错误处理来实现的。而 sys.getfilesystemencoding()
的取值与 nl_langinfo(COODESET)
相关,即 LC_CTYPE
环境变量。
所以,导致这部分环境变量包含 Surrogate 的原因就是 LC_TYPE
指定的编码不正确。
当环境变量 LC_CTYPE
、LC_ALL
、LANG
均没有显式定义时,各个操作系统有各自的 LC_CTYPE
的缺省值(比如 C
)。这种情况下,通过 sys.getfilesystemencoding()
通常会得到 ascii
,环境变量中包含非 ASCII 字符就会得到上面的 Surrogate。
真正的问题
到了这里,问题根源和解决方法基本上已经水落石出了。
前面说手动跑 Worker 可以但是 supervisor 跑 Worker 就不行,这是因为被 supervisor 管理的子进程并不会单独开 Shell 而是继承 supervisord
的 Shell,所以基本的环境变量也有不同。
解决方法便是在 supervisor 配置中修改 environment,加入 LC_CTYPE
或者 LC_ALL
或者 LANG
环境变量的配置。
附,LANG
、LC_ALL
、LC_TYPE
什么的之间到底是个啥关系:查找 LC_X
的值的过程是:LC_ALL
有值就用 LC_ALL
的值;否则 LC_X
有值就用 LC_X
的值;否则 LANG
有值就用 LANG
的值;否则就用操作系统的默认值。
以上。
参考文档
标题剧透预警,然而已经晚了 :)
事情是这样的:这两天在写某 Flask App 的用户模块,访问邮箱验证邮件里的链接时,SQLAlchemy 在 db.session.add(user)
时有一定几率抛出异常:
AssertionError: A conflicting state is already present in the identity map for key (, (UUID('12345678-1234-1234-1234-123456789abc'),))
重现模式也很奇怪,同一个链接,首次访问时几乎都是异常,而刷新一下重新访问,一切就平平稳稳通过了。但是如果开新的浏览器(新的隐身窗口)访问,似乎无论是不是首次访问都没有问题。
问题是什么
断言内容直译是「目前的 Identity Map 中已存在与之冲突的状态」。
SQLAlchemy 是数据库和 Python 的中间层,Identity Map 即数据库对象与 Python 对象的映射表。我们之所以可以做这样的判断:
new_user = User(id=42)
db.session.add(new_user)
user = User.query.get(42)
assert user is new_user
就是 Identity Map 的功劳。
如果试图往 Identity Map 里加入两个不同的 Python 对象,但这两个 Python 对象都映射到同一个数据库对象,自然就不科学了,因为打破了其中的不变量。
foo = Model(id=42)
db.session.add(foo) # OK
bar = Model(id=42)
db.session.add(bar) # Fail
问题的成因
系统中有一个 get_user_by_id
的函数用以从数据库查找用户。这个函数额外加了个自己实现的 @cache
装饰器,用 Redis 做了缓存。
发送验证邮件时,因为其它部分的逻辑对用户信息做了修改,导致缓存被清空。于是下一次对 get_user_by_id
的调用一定会走数据库。
点击邮件中的验证链接后,访问验证页面。由于在 app.before_request
中插入了用 Session 中的 user_id
通过 get_user_by_id
获取用户信息的操作,做了数据库访问, Identity Map 中就保存了这一份 UserModel
,暂且称作 A。
正式的验证页面逻辑,需要从验证链接中提取到这条链接对应的 user_id
,继而通过 get_user_by_id
获得对应的 UserModel
,此时的 UserModel
是从 Redis 中反序列化而来的,暂且称作 B。
A 和 B 虽然指向同一个数据库对象,但其实是不同的 Python 对象。通过 db.session.add
把 B 加入 Session,就会与 Identity Map 里原有的 A 发生冲突。
所以第二次访问该链接,由于都是从 Cache 加载的对象,Identity Map 一直是空的,就不会有如何问题;打开新浏览器,由于没有登录,也不会触发 app.before_request
中的数据库操作,所以也不会有问题。
问题的解决?
有一个解决方法,是新开一个 get_user_by_id_for_update(user_id)
从数据库加载,与此同时,正好可以在该函数内部给数据库请求加上 with_for_update()
给该记录加锁。
其它的选项还包括:
- 在
@cache
里为带 Cache 的函数都加上 force
参数的支持
- 个人觉得,
force
参数的「跳过缓存」语义暴露了实现细节,相比之下, get_model_by_id_for_update
的「修改」语义比它高到不知道哪里去了
- 统一把
update_model(model, **kwargs)
改成 update_model(model_id, **kwargs)
的形式,在内部做数据库查询
- 个人觉得,将
update_model
改为接受 model_id
的做法则有点本末倒置的意思
还是用 merge
更好啦
距离原来这篇博客发出已经一年多了,期间一直在遵循着上面所说的解决办法。但最近因为需要自己为 SQLAlchemy 封装 Write-through Cache 的轮子,又仔细阅读了 SQLAlchemy 的文档,发现在出现此种状况也可以选择使用 session.merge(obj, load=False)
将对象直接放入本 session 的 identity map 中。
session.merge
会首先根据对象的主键在 identity map 中查找对象,如果没有找到,则从数据库中加载一个进来。然后,会将传入对象的状态同步给 identity map 中的对象。
添加 load=False
参数,就会跳过「从数据库中加载一个」的步骤,直接在 identity map 中新建一个对象。由于对对象的状态有严格要求,官方文档 中只推荐从缓存中加载时使用。不过这正是我们所需要的,哈哈。
以上。
先说一个比较囧的事情:在写虾米音乐试听下载器的时候遇到一个问题,因为保存的文件都是用音乐的标题命名的,所以碰到一些诸如「対峙/out border」等含有非法字符(哼哼,说的就是你 →_→ Windows)的标题的时候,就会保存失败。于是我想起了迅雷的解决方法:把所有的非法字符替换成下划线。
于是就引入了正则表达式的使用。一番搜索囫囵吞枣后,我写下了这样的函数:
def sanitize_filename(filename):
return re.sub('[\/:*?<>|]', '_', filename)
最近意识到了这个函数里的好多问题:
- Python 和 Shell 不同,无论单引号还是双引号,反斜杠都是转义符。走狗屎运的是,Python 对于没意义的转义
\/
的处理是保持原样。
- 即便如此,
sanitize_filename('\\/:*?<>|')
依旧返回 \_______
而不是全部都是下划线。
于是感觉得正正经经看看文档了。
Raw strings
看了文档后才意识到,Python 正则表达式模块的转义是独立的。例如匹配一个反斜杠字符需要将参数写成:'\\\\'
:
- Python 将字符串转义:
\\\\
被转义为 \\
re
模块获得传入的 \\
将其解释为正则表达式,按照正则表达式的转义规则将其转义为 \
如此麻烦的前提下,Raw String 就大有作为了,顾名思义就是(除了结尾的反斜杠)不会被转义的字符串。于是匹配一个反斜杠字符就可以写作 r'\\'
。
所以上面的 sanitize_filename
改成了:
def sanitize_filename(filename):
return re.sub(r'[\\/:*?<>|]', '_', filename)
Regex 和 Match
于是正经看看 re
模块吧~以下为流水帐,供急性子观看。
Python 的正则表达式模块 re
中主要的对象其实是这俩:
- 正则表达式
RegexObject
- 匹配
MatchObject
RegexObject
是正则表达式对象,所有 match
sub
之类的操作都归它所有。由 re.compile(pattern, flag)
生成。
>>> email_pattern = re.compile(r'\w+@\w+\.\w+')
>>> email_pattern.findall('My email is abc@def.com and his is user@example.com')
['abc@def.com', 'user@example.com']
其中的方法:
search
从任意字符开始匹配,返回 MatchObject
或者 None
match
从第一个字符开始匹配,返回 MatchObject
或者 None
split
返回由匹配分割的 List
findall
返回所有匹配的 List
finditr
返回 MatchObject
的迭代器
sub
返回替换后的字符串
subn
返回 (替换后的字符串, 替换次数)
re
模块里提供的函数如 re.sub
re.match
re.findall
实际上都可以认为是一种省去直接创建正则表达式对象的捷径。而由于 RegexObject
对象本身可以反复使用,这也是它相对于这些捷径函数的优势所在。
MatchObject
则是匹配对象,表示一次正则表达式匹配的结果。由 RegexObject
的一些方法返回。匹配对象永远是 True
的,另外还有一大堆用来取得正则表达式中分组(group)相关信息的方法。
>>> for m in re.finditer(r'(\w+)@\w+\.\w+', 'My email is abc@def.com and his is user@example.com'):
... print '%d-%d %s %s' % (m.start(0), m.end(0), m.group(1), m.group(0))
...
12-23 abc abc@def.com
35-51 user user@example.com
参考内容:The Python Standard Library
最近做视频需要从虾米上找些音乐做 BGM 用,无奈从虾米上下音乐是要花「米」这种虚拟货币的。所幸的是试听是完整的,而且就我的耳朵而言听不出这个「试听」的音质有什么变化,加上最近在学 Python,就写了这么一个东西。
原理依旧简单:歌曲和专辑都有 ID(从 URL 上看得出来),试听播放器根据 ID 拼接地址得到一个 XML 播放列表文件。而这个播放列表就是需要在试听播放器里添加的播放列表。其中表示位置的 location 字段是被加密过的,类似于
6hAFat2221F19E4pt%fm%FF%6%78853t23i21528579_3pF..F98F2E%136%%xn4275%15%2.32ie%%912_E57m
仔细观察,或者对照实际 URL 可以看出可以将这一串字符写作如下形式:
6
hAFat2221F19E4p
t%fm%FF%6%78853
t23i21528579_3
pF..F98F2E%136
%%xn4275%15%2.
32ie%%912_E57m
其中第一个「6」表示将后面的内容折成六行。如此处理后的内容,就成为了前几天特别流行的藏头了。
http%3A%2F%2Ff3.xiami.net%2F4%2F192%2F58792%2F511682%2F%5E1_177%5E9891%5E8_3274536.mp3
进行 unquote 后变成
http://f3.xiami.net/4/192/58792/511682/^1_177^9891^8_3274536.mp3
继而将 ^ 替换为 0 就是最终的 URL 了!
GitHub Repo: https://github.com/timothyqiu/xiami-downloader
- 1
- 2
- »