修改 Python Traceback 输出内容——以 NTTS 项目作例

Foreword

前段时间,遥遥领先发布了一款新的大模型——昇腾。

在发布会上,闹了这么一个乌龙:演示人员试图现场演示生成图片,出现了图示报错:

hw_shengteng_sleep6

图示报错是在终端按 Ctrl-C 终止 Python 程序运行(触发 KeyboardInterrupt)时,报错回溯显示代码停留在了一个延迟等待的函数: time.sleep(6)

于是网上各种人便开始以此输出『华为现场演示大模型其实是调用外部提前生成好的图片!是有人在背后人工操控!』。这或许是受 Harmony OS 发行版前四个版本的影响而形成的华为『撒谎者』的刻板印象,但是随后官方进行了解释:

代码中有 time.sleep(6) 等表述,是命令等待读取外部开源大模型实时生成的图片,并非调取预置图片。本次展示的均为真实代码,也将在昇腾社区上开放,欢迎开发者使用并提出宝贵建议。

虽然是真是假还得等该产品面向广大个人用户免费开放后才能验证,但是已经有人在 GitHub 搞了个整活仓库,名为 NTTS,很像前段时间的 CEC-IDE 系列整活仓库。

这个整活仓库的远看像是某个牛逼的文字转语音模型(TTS, Text To Speech),近看其实是没时间睡觉了(There is No Time to Sleep)。是不是感觉被骗了?别急,细看整个仓库,包括自述文件和代码,你会发现,整个项目本来就是用来骗人的


Intro

还在为 Ctrl+C 时 Traceback 停在 time.sleep() 上而感到尴尬吗?在你的项目中引入这个包吧,它会把所有的 KeyboardInterrupt 输出 Traceback 信息的最后一行替换成 model.py,让你的代码看起来就像是在跑一个神经网络模型一样酷炫!

上面就是仓库自述文件对该项目主要功能的介绍。没错,它通过修改报错信息让那些半懂半不懂的初学者产生对你的崇拜——天哪,这个大佬竟然在手搓大模型!!!

Usage

Installation

按照官方介绍的方法获取到整个项目,你可以选择将该项目的整个 NTTS 文件夹手动复制到你的项目文件夹(推荐)或第三方库安装目录 site-packages ,或者直接用 pip install NTTS(后两者不推荐因为滥用会导致整个环境被污染,毕竟这只是一个整活项目)。

Import

在你的项目代码文件顶部通过 import NTTS 导入这个包,其它啥都不用干,该正常写其它东西继续写。

Run

运行你的程序,在还没运行结束的情况下(比如你用 time.sleep 控制了延迟),按下 Ctrl-C,原本应该显示代码运行停留的位置和语句,现在换成了类似这样(Environment: Windows):

Traceback (most recent call last):
  File "C:\Users\Administrator\Desktop\NTTS-main\test.py", line 10, in <module>
    b()
  File "C:\Users\Administrator\Desktop\NTTS-main\test.py", line 8, in b
    a()
  File "C:\Users\Administrator\AppData\Local\Programs\Python\Python312\Lib\site-packages\mindx\model.py", line 23, in a
    Model.inference(img)
KeyboardInterrupt

然鹅,如果你顺着 <Python安装位置>\Python<版本号>\Lib\site-packages 这个目录去找 mindx\model.py 这个文件,你会发现根本找不到。这一切的实现还得让我们细看这个多文本转语音模型没时间睡觉了项目。

Principle

按照调用顺序,我们将依次对函数和文件进行介绍。

ntts.py

需要导入的包

import os
import sys
import traceback
import site
import getpass

ntts.get_package_path()

这个不用多说,就是返回 Python 环境的用 pip 安装的第三方库和模块的安装位置。

def get_package_path():
    if hasattr(site, 'getsitepackages'):
        packages = site.getsitepackages()
        if len(packages) > 1:
            return packages[1]
        if len(packages) > 0:
            return packages[0]
    if sys.platform.startswith("linux"):
        return f"/home/{getpass.getuser()}/anaconda3/envs/torch/lib/python{sys.version_info.major}.{sys.version_info.minor}/site-packages"
    elif sys.platform == "win32" or sys.platform == "cygwin" or sys.platform == "msys":
        return f"C:/Users/{os.getlogin()}/anaconda3/envs/torch/Lib/site-packages"
    elif sys.platform == "darwin":
        return f"/Users/{getpass.getuser()}/anaconda3/envs/torch/lib/python{sys.version_info.major}.{sys.version_info.minor}/site-packages"
    return f"/usr/local/lib/python{sys.version_info.major}.{sys.version_info.minor}/site-packages"

对于后面部分的疑问,有个傻逼我已经问过仓库主,Anaconda 环境下 site.getsitepackages 可能不起作用或返回错误结果。(犯罪现场

ntts.reformat(frame: dict[str, str], format: list[str])

def reformat(frame, format):
    format[0] = 'Traceback (most recent call last):\n' + format[0]
    row = []
    row.append(f'  File "{frame['filename']}", line {frame['lineno']}, in {frame['name']}\n')

    if frame['line']:
        row.append(f'    {frame['line'].strip()}\n')
    # if frame['locals']:
    #     for name, value in sorted(frame['locals'].items()):
    #         row.append(f'    {name} = {value}\n')
    # if frame['exname']:
    #     row.append(frame['exname'])
    row.append('KeyboardInterrupt')
    result = ''.join(row)
    format[-1] = result
    return ''.join(format)  # 此句被我修改,原句仅为 return format

代码做了一些注释化处理,因为一些代码,至少在这个项目、这个项目的目的下,根本访问不到。

这个函数将处理出完整的报错消息(仿真),上述代码经过修改,不是返回所有行的字符串列表,而是拼接好的整个字符串(可打印)。

ntts.excepthook_decorator(excepthook: sys.excepthook, filename: str, lineno: int, line: str)

def excepthook_decorator(excepthook: sys.excepthook, filename: str, lineno: int, line: str):
    def wrapper(exctype, value, exctracback):
        if exctype is KeyboardInterrupt:
            format = traceback.format_tb(exctracback)
            frame = {
                'filename': filename,
                'lineno': lineno,
                'name': format[-1].split('\n')[0].split(' ')[-1],
                'line': line,
                # 'locals': None,
                # 'exname': 'KeyboardInterrupt'
            }
            msg = reformat(frame, format)
            print(msg, file=sys.stderr)
        else:
            excepthook(exctype, value, exctracback)

    return wrapper

资深玩家应该已经看得出来这是一个装饰器(decorator),只不过这里不再是通过装饰器的正常用法进行调用。装饰器的使用是用在变量(主要是类/方法/函数)定义的前一行,用 @decorator 的方式装饰变量,但是由于这个装饰器的应用对象—— sys.excepthook 是一个提前定义好的变量——甚至是 Python 预置库。我们当然无法在其定义的时候进行装饰(即使可以实现,也不建议),但是我们需要的是对 sys.excepthook 临时装饰——别忘了,装饰器本身就是一个函数,我们可以用函数定义的方式使用它装饰另一个变量,并通过赋值覆盖一些变量的行为(后面的 __init__.py 里会讲)。

子函数 wrapper 是个封套,它接受和 sys.excepthook 一样的参数,这是装饰后的函数的模板。我们的目的仅仅是修改按下复制快捷键触发的错误的输出,所以我们判断异常类型是否为 KeyboardInterrupt,如果不是则返回原函数,是的话开始自定义行为:

首先通过 traceback.format_tb 获取源报错对象的堆栈跟踪条目列表,赋值给 format。接着构建一个框架(使用字典),参数和“预处理”堆栈跟踪条目 FrameSummary 对象的属性一致。只不过在这里,几乎所有的参数都变成了自定义值的,如 filenamelinenoline,唯一不需要手动指定的 name(报错发生时指定到的对象),也是由原报错决定。

__init__.py

需要导入的包

from .ntts import excepthook_decorator, get_package_path
import os
import sys

代码正文则只有短短两行:

package_path = get_package_path()
sys.excepthook = excepthook_decorator(sys.excepthook, os.path.join(package_path, 'mindx', 'model.py'), 23, 'Model.inference(img)')

第一行获取第三方包安装目录;

第二行覆盖 sys.excepthook 的行为,使之在捕获 KeyboardInterrupt 时执行一些别的操作。同时,它将包目录、一个指定的文件夹和一个指定的文件名的拼接一个数字一行代码作为剩下的参数传入。三个参数分别为伪造文件的路径、伪造的问题代码所在行编号和伪造的问题代码具体语句,都是高度可自定义化的。仓库官方文件这么整,就是为了有逼格——想象一下,报错文件是一个模型模块,问题代码行编号随机,问题代码是用模型对象生成图片……这不得让那些对此了解并不多的人惊叹于你的伟大

这里我们注意到了前面提到的装饰器派上用场了。这里的第二行直接用函数调用的方式手动装饰了标准库中的函数,原理和原本装饰器的使用习惯其实是一样的:

def decorator(func, *args, **kwargs):
    def wrapper():
        # Something before executing
        func()
        # Something after executing
    return wrapper

@decorator(*args, **kwargs)
def function(*args, **kwargs):
    pass

装饰器的第一个参数都是要装饰的函数本身,所以在定义时用 @decorator 装饰函数会自动将函数作为第一个参数,而你需要在装饰器后面括号里填的(可选,看你怎么写装饰器)是从第二个位置参数开始的(或其它关键字参数)。再通过赋值的方法,实现了临时(仅限单次运行内)修改函数行为。

Summary

修改了 sys.excepthook 后,正如 ntts.py 中的规定,当用键盘手动中断程序运行,原本应该按序报错打印条目的过程,条目数据被修改,导致报错内容稍异。

综上,在整个篡改报错的过程中,我们使用到了:获取包安装目录(ntts.get_package_path)、装饰器(ntts.excepthook_decorator)、内置函数临时覆写(__init__.sys.excepthook)等方法,涉及基础数据操作有字符串和列表的操作(ntts.reformat)。通过对特定错误(KeyboardInterrupt)的捕获重写,修改数据和输出内容,实现耀眼的自定义报错。

在实际使用中,如果有需要,可以复制这个项目到你的项目目录作为包,修改 __init__.py 中临时重写 sys.excepthook 那行中 excepthook_decorator 的第 2 到第 4 个参数,再用 import NTTS 将包导入到你的项目中。

时隔多时又水一篇,还望谅解。

阅读剩余
THE END