TimothyQiu's Blog

keep it simple stupid

psycopg2 的导入警告

分类:技术

最近发现如果使用最新的 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 版本开始将不再包含预编译二进制,必须通过编译安装。为此还作出了两个调整:

  1. 2.7.4 ~ 2.8 之间为过渡期,使用 psycopg2 的预编译二进制时会生成警告
  2. 新增了 psycopg2-binary 包,除了仍旧提供预编译二进制外,与 psycopg2 完全一致(即与老版本行为一致)。

找了一下相关的讨论,感觉比较赞同这个 Issue 中的反对观点:

官方在说明中推荐开发用 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 的 surrogateescape

分类:技术

新项目中第一次开始作死使用 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_CTYPELC_ALLLANG 均没有显式定义时,各个操作系统有各自的 LC_CTYPE 的缺省值(比如 C)。这种情况下,通过 sys.getfilesystemencoding() 通常会得到 ascii,环境变量中包含非 ASCII 字符就会得到上面的 Surrogate。

真正的问题

到了这里,问题根源和解决方法基本上已经水落石出了。

前面说手动跑 Worker 可以但是 supervisor 跑 Worker 就不行,这是因为被 supervisor 管理的子进程并不会单独开 Shell 而是继承 supervisord 的 Shell,所以基本的环境变量也有不同。

解决方法便是在 supervisor 配置中修改 environment,加入 LC_CTYPE 或者 LC_ALL 或者 LANG 环境变量的配置。

附,LANGLC_ALLLC_TYPE 什么的之间到底是个啥关系:查找 LC_X 的值的过程是:LC_ALL 有值就用 LC_ALL 的值;否则 LC_X 有值就用 LC_X 的值;否则 LANG 有值就用 LANG 的值;否则就用操作系统的默认值。

以上。

参考文档

SQLAlchemy 的 Identity Map 和 Cache 造成的 add 失败

分类:技术

标题剧透预警,然而已经晚了 :)

事情是这样的:这两天在写某 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() 给该记录加锁。

其它的选项还包括:

还是用 merge 更好啦

距离原来这篇博客发出已经一年多了,期间一直在遵循着上面所说的解决办法。但最近因为需要自己为 SQLAlchemy 封装 Write-through Cache 的轮子,又仔细阅读了 SQLAlchemy 的文档,发现在出现此种状况也可以选择使用 session.merge(obj, load=False) 将对象直接放入本 session 的 identity map 中。

session.merge 会首先根据对象的主键在 identity map 中查找对象,如果没有找到,则从数据库中加载一个进来。然后,会将传入对象的状态同步给 identity map 中的对象。

添加 load=False 参数,就会跳过「从数据库中加载一个」的步骤,直接在 identity map 中新建一个对象。由于对对象的状态有严格要求,官方文档 中只推荐从缓存中加载时使用。不过这正是我们所需要的,哈哈。

以上。

Python 正则表达式

分类:技术

先说一个比较囧的事情:在写虾米音乐试听下载器的时候遇到一个问题,因为保存的文件都是用音乐的标题命名的,所以碰到一些诸如「対峙/out border」等含有非法字符(哼哼,说的就是你 →_→ Windows)的标题的时候,就会保存失败。于是我想起了迅雷的解决方法:把所有的非法字符替换成下划线。

于是就引入了正则表达式的使用。一番搜索囫囵吞枣后,我写下了这样的函数:

def sanitize_filename(filename):
    return re.sub('[\/:*?<>|]', '_', filename)

最近意识到了这个函数里的好多问题:

于是感觉得正正经经看看文档了。

Raw strings

看了文档后才意识到,Python 正则表达式模块的转义是独立的。例如匹配一个反斜杠字符需要将参数写成:'\\\\'

  1. Python 将字符串转义:\\\\ 被转义为 \\
  2. re 模块获得传入的 \\ 将其解释为正则表达式,按照正则表达式的转义规则将其转义为 \

如此麻烦的前提下,Raw String 就大有作为了,顾名思义就是(除了结尾的反斜杠)不会被转义的字符串。于是匹配一个反斜杠字符就可以写作 r'\\'

所以上面的 sanitize_filename 改成了:

def sanitize_filename(filename):
    return re.sub(r'[\\/:*?<>|]', '_', filename)

Regex 和 Match

于是正经看看 re 模块吧~以下为流水帐,供急性子观看。

Python 的正则表达式模块 re 中主要的对象其实是这俩:

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']

其中的方法:

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