Python import机制探究

最近遇到一个需求,需要动态加载一堆 Python 模块,但是这些 .py 是 pb 文件生成的,无法控制命名,导致导入的时候各种报错。

目录结构

package1.py 内容

package2.py 内容

package3.py 内容

可以看见在 basic.package1 中导入了 package2、package3。我们在 main 文件中导入 basic 包。

这样肯定不行,报错了:ModuleNotFoundError: No module named 'basic'

sys.path

Google 一下或者稍微了解 Python 的就会知道,Python 导入非系统库需要指定 PYTHONPATH,Python 启动时会读取此环境变量并加入到 sys.path 中。我们可以打印下:

可以看到默认加载了当前项目目录、Python系统目录以及 dist-packages(通过 pip 安装的),我们把 temp_dir 目录加进去就行了。代码增加:

但还是会报错:ImportError: cannot import name 'package2' from 'calendar' (/usr/lib/python3.8/calendar.py),不过至少说明,加载路径是设置成功了,basic 包本身导入成功了,只是 from calendar import package2 执行失败了。

这个是一个经典错误,搞 Python 的都会遇到,那就是自己定义的包名不能和系统的重复,一般是改个名字或者加上命名空间,但是我们这个场景中,Python 文件是 pb 生成的,无法修改源文件,修改生成的文件也比较麻烦。也能解决,就是提高我们路径的优先级,改成 sys.path.insert(0, temp_dir) 加载完了再改回来。

BuiltinImporter

不过又有新的报错:ImportError: cannot import name 'package3' from 'math' (unknown location),有输出 import package2,看着还是导入了系统的 math 库,没有导入自己的,还没有显示路径。这里匪夷所思,卡了很久,继续单步调试代码和查阅资料,发现 Python 其实是支持多种导入方式,甚至可以自定义。importlib._bootstrap._find_spec 中,是循环 sys.meta_path 中的 Importer 哪个找到算哪个。我们可以打印下 sys.meta_path:

可以看见最前面有个 BuiltinImporter,唉,灵光一闪,math 就是一个内建库呀,这里结合源码猜测,C 的库都是通过这个导入的。去掉这个就能导入成功了(记得加载完了还原回来)。

终于成功了:

sys.modules

不过,这里只是个 demo。实际情况比这个复杂,还是会报 ImportError: cannot import name 'package2' from 'calendar' (/usr/lib/python3.8/calendar.py),这里很奇怪,已经保证了加载的优先级,但为什么还不行呢?没办法,扒一下源码,终于找到了一点端倪。importlib._bootstrap._find_and_load 方法中,会先在 sys.modules 里面找,如果有则直接返回,官方文档也有相应说明。

所以还得排除已加载的情况

最终到此才彻底解决,完整 demo 代码:https://github.com/iyaozhen/python-import-test

参考资料

https://docs.python.org/3/library/importlib.html

妙用Hook来研究Python的Import机制,https://zhuanlan.51cto.com/art/201701/527713.htm

Python3.7源码剖析之import,https://zhuanlan.zhihu.com/p/361720373

Python使用logging为Flask增加logid

我们为了问题定位,常见做法是在日志中加入 logid,用于关联一个请求的上下文。这就涉及两个问题:1. logid 这个“全局”变量如何保存传递。2. 如何让打印日志的时候自动带上 logid(毕竟不能每个打日志的地方都手动传入)

logid保存与传递

传统做法就是讲 logid 保存在 threading.local 里面,一个线程里都是一样的值。在 before_app_request 就生成好,logid并放进去。

因为需要一个数字的 logid 所以简单使用 uuid.uuid1().time 一般并发完全够了,不会重复且趋势递增(看logid就能知道请求的早晚)。

打印日志自动带上logid

这个就是 Python 日志库自带的功能了,可以使用 Filter 来实现这个需求。

log_format 中我们用了很多系统自带的占位符,但 %(logid)s 默认没有的。每条日志打印输出前都会过 Filter,利用此特征我们就可以把 record.logid 赋值上,最终打印出来的时候就有 logid 了。

虽然最终实现了,但因为是通用化方案,所以有些复杂了。其实官方教程中介绍了一种更加简单的方式:injecting-request-information,看来没事还得多看看官方文档。

iTerm2进阶使用技巧

iTerm2 的优点这里不做赘述了,第一次使用的话可以先看看官网的介绍:featuresHighlights for New Users。本文主要是结合实际使用场景,介绍一些进阶使用技巧(基本的 Oh My Zsh、rzsz 等配置就不重复说明了)。

跳板机自动登录

现在很多公司登录服务器都需要先登录到一个跳板机然后再登录到目标机器,每次输入密码(一般还是动态的)很麻烦。一般的教程推荐使用 expext 解决这个问题,这里介绍一个更简单、直接的办法。

首先,要先解决登录到跳板机的连接复用问题,这个输入 ssh 本身的范畴,一般教程都是说在 ~/.ssh/config 增加下面的配置:

但这样有个问题,iTerm2 第一次登录的 tab 关闭后,就失效了,再登录就又要密码了。其实再增加一个配置即可。

之前的设置只是实现了连接复用,但 tab 关闭 ssh 进程结束后,连接也被销毁了,ControlPersist 项的意思是进程结束后连接还保持,再有相同的(ControlPath配置)连接,还能继续使用此连接。

再者,每次输入 ssh xxx 很麻烦(而且跳板机登录后还得输入 ssh xxx),当然你可以配置 alias+expext 但也不是太方便,特别是 Windows 转过来的。对此,iTerm2 本身也提供了很强大的 Profiles 功能,能一键登录目标机器(还可以设置快捷键)。

有些自己的常用机器,还可以复制一下这份基础的 relay 配置。

再加一行(Send text at start处)。

还可以把机器打个标签,然后页面上就能分组,方便选择。

还有一个,有时候会发现,有些机器一会儿没操作就会被断开 ssh,一方面可以设置 ServerAliveInterval,但作用不大,因为实际连接服务器的是 relay 跳板机。iTerm2 其实没有可以显式设置这个功能的地方,有些同学可能找到个这个配置: Continue Reading...

Python Requests Session set-cookie不生效的坑

我们知道 Python Requests库 中的 Session 模块有连接池和会话管理的功能,比如请求一个登录接口后,会自动处理 response 中的 set-cookie,下次再请求时会自动把 cookie 带上。但最近出现了一个诡异的事情,cookie 没有自动带上,导致请求 403。

一开始怀疑是登录接口错误了,没有 set-cookie,但抓包发现 response header 中有 set-cookie,打印请求的 response.cookies 也有需要的 cookie。又怀疑是 set-cookie 的格式不对或者其它问题,但用浏览器实际跑了下流程,发现系统一切正常,那基本就是 requests 库的问题了。

没办法,只能 debug 了,单步调试了几轮,基本了解了 requests 的处理方式,首先把请求参数转变为 Request 对象,然后对使用 prepare_request 对 Request 进行预处理,其中有一步 merge_cookies 的操作(还有各种其它处理),把传入的 cookies 和 self.cookies merge 到 RequestsCookieJar 对象上去,这一步也没啥问题,merged_cookies 变量也是对的。后续将预处理过的请求,通过内置的 http adapter 发送出去。http adapter 底层是通过 urllib3.poolmanager 获取到 urllib3.connectionpool 连接(这里是连接池的核心部分),再通过 conn.urlopen 实际发送请求。虽然跟踪了解到了整个请求逻辑,但最终发出的请求还是没有带上需要的 cookie。

问题定位一度陷入僵局,只能再回顾上面的流程,cookie 肯定就是在 merged_cookies 和 conn.urlopen 之间没的,再仔细观察发现,conn.urlopen 请求参数里面压根没有 cookie 字段。

查阅资料发现,urllib3 的作者说,连接池只处理底层连接,cookie 跟踪等事情应该上层来做。大胆猜测,那 cookie 应该是放在 header 里了,往前捣看看 request.headers 是怎么变动的(此时里面的 Cookie 字段确实不正确)。

再走查代码发现 prepare_request 里面是调用了 PreparedRequest.prepare,其中有一步 prepare_cookies,主要是调用了 cookielib.CookieJar.add_cookie_header 最终将 cookie 放到了 self.headers['Cookie']。但里面有个 request.has_header("Cookie") 的判断,header 中没有 Cookie 字段才会放,不知道为什么这么考虑(最新版 3.8 还是这样),估计是 merge cookie 比较麻烦,但问题确实就出在这里,这次请求之前,Requests Session 已经直接通过 headers['Cookie'] 设置了 cookie。复现代码(修改自官方示例):

虽然找到了问题的原因,但又不好解决,总不能不让直接操作 headers['Cookie'] 吧,先不说无法限制使用者,而且之前的代码已经这样做了,改动量非常之大。不过好在,现在自己用的框架是在 Requests Session 上封装了一层,操作 header 都是调用的统一的 update_headers 方法:

对 headers['Cookie'] 的操作拦截一下,变成对 cookies 的操作:

这样即不用改之前的调用方代码,也防止了后人掉坑。

 

参考资料

https://requests.readthedocs.io/en/master/user/advanced/#session-objects

https://stackoverflow.com/questions/2422922/python-urllib3-and-how-to-handle-cookie-support

https://urllib3.readthedocs.io/en/latest/reference/index.html#module-urllib3.poolmanager

https://urllib3.readthedocs.io/en/latest/reference/index.html#urllib3.connectionpool.HTTPConnectionPool.urlopen

https://docs.python.org/2/library/cookie.html

使用 Python nose 组织 HTTP 接口测试

现在前端 Web、移动端 APP 开发基本上是面向接口编程,后端各种 HTTP 接口已经提供好了,大家只要实现逻辑交互和展示就行。那么问题来了,这么多接口如何方便的进行测试呢?根据个人经验,我认为需要解决三个问题:1. HTTP 接口多种多样,测试程序如何统一?2. 测试程序如何规范组织?3. 接口间有上下文依赖,如何解决?

HTTP 接口多种多样,测试程序如何统一?

后端接口可能来自各个系统,GET/POST 协议的、遵循 RESTful 规范的,使用 session 认证、token 认证的,各式各样的接口都存在。但无论怎么变都无法脱离 HTTP 协议。因为组内的技术栈是 Python,这就很容易想到使用 Python 的 requests 库。首先我们使用requests.Session()会话对象,来进行会话保持和 Header、Cookie 等处理。这里我们可以简单封装一个 HttpSession 类,把一些常用操作和设置封装一下:

通过HttpSession()来获取一个requests session对象,后续的 HTTP 请求都通过它发起。requests 提供 get()、post() 等各种方法调用,但这会让测试代码显得不统一,这里我们直接调用底层的 request 方法,该方法几乎提供了发起一个 HTTP 请求需要的所有参数,非常强大。

这样每一个 HTTP 接口的测试都可以通过准备参数发起请求断言结果三板斧来解决。

测试程序如何规范组织?

前面我们已经解决了如何快捷的发起一个 HTTP 请求的问题,这让我们几行代码就可以测试一个接口的返回值。但你会发现新的问题又来了,各种脚本散乱在一堆,返回值解析各种中间变量,各种配置硬编码在代码中,测试结果全靠 print,这时 nose 框架就派上用场了。nose 是 Python 最流行的一个单测框架,提供测试 case 设置标签、测试 case 查找、强大的断言函数,格式化/可视化报告输出等功能。这样就可以像写单测一样写 HTTP 接口测试了。我们可以把一类接口的测试放在一个类里面,甚至可以把基础变量的定义放在基础测试类里面,其它测试类继承这个基类。

HttpTest基类只做了两个工作:1. 创建http会话对象;2. 读取配置文件到类变量config中。配置文件是一个很好的编程实践,这让我们测试程序和数据能够分离,可以通过调用不同的配置文件来测试不同环境的接口,让我们测试程序不光能做线下测试,还能做线上回归和监控,切换成本非常低。

我这里选择 yaml 语法来组织配置信息,yaml 语法风格类似 json,自带基础数据结构,但更加易于书写和阅读,非常适合做配置文件。通过-env=xxx指定配置文件路径(或者 nose.cfg 配置文件中指定),使用 ruamel.yaml 来解析 yaml,转换为 Python 中能直接操作的数据结构。

这一切都放在setUpClass方法中,在具体测试 case 运行之前就已经准备好这些工作了。 Continue Reading...