前言

  最近录了一个关于 overlapper 插件的讲解视频。
  使用 VSCode 调试 Python 代码的时候翻车了,而且使用外部软件调试会冻结 Python
  导致 Maya 没有相应,交互非常不友好。
  于是我就有了开发一个 Debug 插件的想法。
  主要是要解决断点状态下Maya的GUI响应问题,这样可以很直观看到代码对 Maya 进行了哪些操作。

  最开始我是想要在 ptvsd 的模块上调用 API 开发 Debug 工具。
  毕竟我自己做的 VScode 扩展就是基于这个原理,可是后来查了一番之后,发现 ptvsd 是基于 PyDev 的
  API调用上我挖掘不太深,就是感觉太复杂了,很多杂七杂八的功能都不需要。

  而且我想要更加通用性的解决方案,利用代码来插入断点,进入断点之后开启一个 while 死循环来更新 UI 界面。
  断点调试的时候就 break 掉死循环就好了。

  再进一步研究之后,我发现 Python 自带的 pdb 模块就是实现这个功能的。
  于是开始研究 pdb 模块有了今天这篇文章。

PDB 模块使用

  PDB 全称是 Python Dubugger,是 python 的内置模块,可以实现命令行来对 python 代码 Debug
  具体用法可以参考后端大佬的视频

  特点就是在代码里面输入 pdb 的代码,程序会捕获到插入的 pdb 调试断点然后进入断点调试模式。
  缺点就是纯命令行工具,所有的信息显示都需要通过键盘输入来进行调试,真 DOS 时代无鼠标的操作。


  其实 Maya 的官方博客也有提到过 关于 Maya Python Debug 的方案,其中还说到了 利用 pdb 模块debug 的方法 around-corner地址
  不过官方并不推荐使用 pdb ,因为 Maya 的脚本编辑器无法进行命令行输入输出,因此input命令将会弹出一个输入窗口
  这样的交互方式是在是太过不友好了,连官方都吐槽了。。。

  另外 maya 的官方文档也有提到 Python Debug 的方案 Maya 文档
  如何使用 pdb 可以参照 python 的 官方文档
  不过官方文档其实也没有什么代码,都是命令解释,所以也不太友好。


  youtube上也有视频专门讲解在 Maya 如何使用 pdb debug

pdb 源码探讨

set_trace 源码挖掘

  在 pdb 调试中,大多数时候就是在要设置断点的行数前插入一行 pdb 的代码 pdb.set_trace()
  那么这一句代码在背后做了什么呢?
  我们可以去到 Python 的Lib目录下找到 pdb.py 文件
  Maya 的 Python 内置代码封装到了 bin 目录的 python27.zip 压缩包当中
  github上也可以查找到源码(代码是Python3的) https://github.com/python/cpython/blob/master/Lib/pdb.py

  在代码当中搜索 set_trace 可以找到下面这个函数

1
2
def set_trace():
Pdb().set_trace(sys._getframe().f_back)

  Pdb 类里面是找不到 set_trace 函数的
  而通过集成关系可以找到Pdb类是继承于 bdb模块的 Bdb 基类
  因此我们可以去到 bdb.py 查找 set_trace 的代码 | https://github.com/python/cpython/blob/master/Lib/bdb.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def set_trace(self, frame=None):
"""Start debugging from `frame`.

If frame is not specified, debugging starts from caller's frame.
"""
if frame is None:
frame = sys._getframe().f_back
self.reset()
while frame:
frame.f_trace = self.trace_dispatch
self.botframe = frame
frame = frame.f_back
self.set_step()
sys.settrace(self.trace_dispatch)

  一顿操作猛如虎,一头雾水不清楚。 (:з」∠)

frame 类型说明

  首先需要理解参数输入的 frame 是什么
  frame 其实 Python 内置的类,用来描述当前函数域相关的信息。
  inspect 模块里面也有 currentFrame 函数获取当前代码域信息。
  而上面的操作和 sys 模块的私有函数 _getframe 是一样的。

  关于 frame 的类型说明可以参考 python inspect 模块的官方说明
  国内还有大佬写了关于 Cpython 里面 frame object 底层的一些生成原理 链接
  可惜这个文章太过深奥,我就跳过了。

  下面则是一些其他国内文章,内容通俗易懂,可惜缺乏深度↓↓↓

  后来在 stack overflow 找到一篇问题
  通过文章回答的链接找到了一篇很好的 frame 运行的代码 链接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import sys

def one():
two()

def two():
three()

def three():
for num in range(3):
frame = sys._getframe(num)
show_frame(num, frame)

def show_frame(num, frame):
print frame
print " frame = sys._getframe(%s)" % num
print " function = %s()" % frame.f_code.co_name
print " file/line = %s:%s" % (frame.f_code.co_filename, frame.f_lineno)

one()

# Output
# <frame object at 0x606c50>
# frame = sys._getframe(0)
# function = three()
# file/line = stack.py:12
# <frame object at 0x180be10>
# frame = sys._getframe(1)
# function = two()
# file/line = stack.py:7
# <frame object at 0x608d30>
# frame = sys._getframe(2)
# function = one()
# file/line = stack.py:4

  通过 frame 可以很好查找到当前代码运行的信息,所以在 pdb 和 traceback 模块里面都是非常重要的组成元素

sys.settrace 讲解

  了解了 frame 之后,我们可以继续测试后续的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
def set_step(self):
"""Stop after one line of code."""
# NOTE Issue #13183: pdb skips frames after hitting a breakpoint and running
# NOTE step commands.
# NOTE Restore the trace function in the caller (that may not have been set
# NOTE for performance reasons) when returning from the current frame.
if self.frame_returning:
caller_frame = self.frame_returning.f_back
if caller_frame and not caller_frame.f_trace:
caller_frame.f_trace = self.trace_dispatch
self._set_stopinfo(None, None)

def _set_stopinfo(self, stopframe, returnframe, stoplineno=0):
self.stopframe = stopframe
self.returnframe = returnframe
self.quitting = 0
# NOTE stoplineno >= 0 means: stop at line >= the stoplineno
# NOTE stoplineno -1 means: don't stop at all
self.stoplineno = stoplineno

def reset(self):
import linecache
linecache.checkcache()
self.botframe = None
self._set_stopinfo(None, None)
self.lineno = None
self.stack = []
self.curindex = 0
self.curframe = None

def set_trace(self, frame=None):
if frame is None:
frame = sys._getframe().f_back
self.reset() # NOTE 重置定义一些变量 我将代码父对象的代码都整合到一起 如上面的 reset 函数所示
# 遍历找到最底层 frame | 并且设置 f_trace 函数到 trace_dispatch 上
while frame:
frame.f_trace = self.trace_dispatch
self.botframe = frame
frame = frame.f_back
# NOTE 不清楚作用作用 | 注释掉完全不影响程序运行,大概需要 return 触发才可行
self.set_step()
sys.settrace(self.trace_dispatch)

  上面我将 set_trace 调用掉的一些函数也贴出来了。
  我发现最核心的代码其实最后一行 sys.settrace(self.trace_dispatch)
  只有这样在才能触发 断点输入 否则 pdb 不起作用了。


  其实我也不太清楚 sys.settrace 函数是怎么运作的
  刚好又在 Stack Overflow 上找到了一些参考 链接
  根据这个参考可以知道这个函数是一个全局钩子,每个函数调用都会触发到这个函数。
  因此通过这个操作可以实现一些 python 黑科技操作。
  当然正如回答说的,如果不是为 Debug 的话,使用这个函数会让其他开代码的人非常难受,而且会导致性能下降。
  所以我看 pdb 也是看得难受至极了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import sys

def trace_func(frame,event,arg):
# NOTE 获取局部变量 a 并修改变量
value = frame.f_locals["a"]
if value % 2 == 0:
value += 1
frame.f_locals["a"] = value

def f(a):
print a

if __name__ == "__main__":
sys.settrace(trace_func)
for i in range(0,5):
f(i)

# NOTE 输出 ↓↓↓
# NOTE 1
# NOTE 1
# NOTE 3
# NOTE 3
# NOTE 5

  frame 对象里面的 f_trace 属性就相当于 局部钩子函数 链接

trace_dispatch 分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

def trace_dispatch(self, frame, event, arg):
print "trace_dispatch",frame,event,arg
if self.quitting:
return # None
if event == 'line':
return self.dispatch_line(frame)
if event == 'call':
return self.dispatch_call(frame, arg)
if event == 'return':
return self.dispatch_return(frame, arg)
if event == 'exception':
return self.dispatch_exception(frame, arg)
if event == 'c_call':
return self.trace_dispatch
if event == 'c_exception':
return self.trace_dispatch
if event == 'c_return':
return self.trace_dispatch
print 'bdb.Bdb.dispatch: unknown debugging event:', repr(event)
return self.trace_dispatch

  设置了 sys.settrace 之后,无论在 Python 进行任何代码操作都会触发钩子,这就为断点调试做好了准备。
  可以看到钩子函数会默认传入当前触发 frame 和 事件。

  为了更好看清楚这个钩子所做的事情,可以继承 Pdb 对象 然后打印出内部的参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from pdb import Pdb
class TestPdb(Pdb,object):
def trace_dispatch(self, frame, event, arg):
print "trace_dispatch",frame,event,arg
return super(TestPdb,self).trace_dispatch(frame, event, arg)
if __name__ == "__main__":

def combine(s1,s2): # define subroutine combine, which...
s3 = s1 + s2 + s1 # sandwiches s2 between copies of s1, ...
s3 = '"' + s3 +'"' # encloses it in double quotes,...
return s3 # and returns it.
a = "aaa"
b = "bbb"
TestPdb().set_trace()
final = combine(a,b)
print final
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
F:\repo\Pdb_test>"C:/Program Files/Autodesk/Maya2017/bin/mayapy.exe" f:/repo/Pdb_test/frame_test.py
trace_dispatch <frame object at 0x00000222AF1DE048> line None
> f:\repo\pdb_test\frame_test.py(16)<module>()
-> final = combine(a,b)
(Pdb) s
trace_dispatch <frame object at 0x00000222AF243908> call None
--Call--
> f:\repo\pdb_test\frame_test.py(9)combine()
-> def combine(s1,s2): # define subroutine combine, which...
(Pdb) n
trace_dispatch <frame object at 0x00000222AF243908> line None
> f:\repo\pdb_test\frame_test.py(10)combine()
-> s3 = s1 + s2 + s1 # sandwiches s2 between copies of s1, ...
(Pdb) n
trace_dispatch <frame object at 0x00000222AF243908> line None
> f:\repo\pdb_test\frame_test.py(11)combine()
-> s3 = '"' + s3 +'"' # encloses it in double quotes,...
(Pdb) n
trace_dispatch <frame object at 0x00000222AF243908> line None
> f:\repo\pdb_test\frame_test.py(12)combine()
-> return s3 # and returns it.
(Pdb) n
trace_dispatch <frame object at 0x00000222AF243908> return "aaabbbaaa"
--Return--
> f:\repo\pdb_test\frame_test.py(12)combine()->'"aaabbbaaa"'
-> return s3 # and returns it.
(Pdb) l
7 if __name__ == "__main__":
8
9 def combine(s1,s2): # define subroutine combine, which...
10 s3 = s1 + s2 + s1 # sandwiches s2 between copies of s1, ...
11 s3 = '"' + s3 +'"' # encloses it in double quotes,...
12 -> return s3 # and returns it.
13 a = "aaa"
14 b = "bbb"
15 TestPdb().set_trace()
16 final = combine(a,b)
17 print final
(Pdb) n
trace_dispatch <frame object at 0x00000222AF1DE048> line None
> f:\repo\pdb_test\frame_test.py(17)<module>()
-> print final
(Pdb) c
"aaabbbaaa"

  从上面的调试信息可以看到

  • 代码运行的 event 是 line
  • 函数触发的 event 是 call
  • 函数返回的 event 是 return | 并且将返回值 到 arg 上
  • 函数报错的 event 是 exception | 同理可以推断

附注: 测试的时候 super 代码忘记前面添加 return 导致无法 step 进调试函数内部

cmd模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

def dispatch_line(self, frame):
if self.stop_here(frame) or self.break_here(frame):
self.user_line(frame)
if self.quitting: raise BdbQuit
return self.trace_dispatch

def user_line(self, frame):
"""This function is called when we stop or break at this line."""
if self._wait_for_mainpyfile:
if (self.mainpyfile != self.canonic(frame.f_code.co_filename)
or frame.f_lineno<= 0):
return
self._wait_for_mainpyfile = 0
if self.bp_commands(frame):
self.interaction(frame, None)

def interaction(self, frame, traceback):
self.setup(frame, traceback)
self.print_stack_entry(self.stack[self.curindex])
self.cmdloop()
self.forget()

  那么可以知道一般情况下运行代码会执行 dispatch_line 会调用 user_line
  而其中经过我的测试,触发输入调试的代码是 interaction 里面的 cmdloop

  这个 cmdloop 可就是大有来头了,这个和 pdb 模块简称的 Cmd 对象有莫大的关系
  Cmd 对象来自内置 cmd 库,用于快速构建命令行工具的库。
  使用方法可以参考别人研究的 文章
  pdb的输入调用操作是完全基于 cmd 库的标准做的,也难怪我也开始找到一些 help 函数之类的信息却完全找不到调用的地方。
  因为这个调用逻辑封装在 Cmd 对象里面了。

  因此对应的操作函数其实都在 do_next do_list 这些函数上了。
  那么将 cmdloop 替换成函数能否实现 settrace 状态下直接触发想要的调试效果呢?
  答案是否定的,直接替换会报错,看了一下上面文章对 Cmd 的运用范例可以知道。
  cmd 内部也有相应的执行前和执行后的钩子,pdb模块对执行前做了相应的处理,如果去掉就无法执行了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from pdb import Pdb
class TestPdb(Pdb,object):
def interaction(self, frame, traceback):
self.setup(frame, traceback)
self.print_stack_entry(self.stack[self.curindex])
# self.cmdloop()
arg = 'c'
self.precmd(arg)
self.onecmd(arg)

self.forget()

if __name__ == "__main__":

def combine(s1,s2): # define subroutine combine, which...
s3 = s1 + s2 + s1 # sandwiches s2 between copies of s1, ...
s3 = '"' + s3 +'"' # encloses it in double quotes,...
return s3 # and returns it.
a = "aaa"
b = "bbb"
TestPdb().set_trace()
final = combine(a,b)
print final

  只要执行上面的代码就可以无条件跳过所有的断点了。
  当然,如果将 c 改为 l 罗列断点的话就会陷入死循环了 (:з」∠)

总结

  以上就是这次 pdb 研究的情况了,其实还有很多pdb相关的东西没有深入探讨
  这次研究的目的是为了移除 pdb 模块的输入等待方式,打算在 Maya 中引入 UI 相应实现动态 Debug 的效果。(mpdb 插件)
  目前我已经得到了最核心的输入相应代码,因此见好就收了。

  个人觉得 pdb 模块写得非常不友好,注释不太够,太多乱七八糟的参数不明所以,网上讲解的文章也非常少。
  还好自己总算是经过重重险阻找到了解决方案。