Python程序的「加密」:Cython编译
目录
- 背景
- Python程序的「加密」
- 什么是Cython
- Cython用法之setup脚本
- 安装Cython
- 编译前
- 编译后
- 结论
- Cython用法之cythonize命令
- Cython编译引入的类型转换问题
- 问题描述
- 排查思路
- 解决方案
- 反思
背景
近一年来在Python方面做了不少事情:最早接触Python是利用selenium写了一个网页爬虫。2024年上半年利用scikit-learn做了机器学习方面的入门实践;下半年开始接触UE5、Blender等3D建模、视觉相关工具的脚本开发,这些工具都提供Python的开发接口。通过Python学到了不少新知识,感触最深的就是,Python太方便了。有太多太多的库可以被集成调用,几乎除了业务逻辑,技术实现都可以是拿来主义。
因为之前更多的是项目管理,好久没有发coding的博文了,最近准备把关于Python的相关实践总结下,分一篇或多篇博文记录,主要记录实现方案、遇到的问题和解决思路。
涉及的话题如下,想道哪写到哪。
- UE引擎的Python脚本开发,实现批量全自动的渲染队列,并加入通知报警等功能。关键词:UE、ndisplay、渲染队列。
- Blender的Python脚本开发,实现自定义的模型生成。关键词:obj文件类型、uv展开、用shapely对二维几何图形的计算、rectpack二维矩形打包问题、pillow图像生成。
- Python程序的打包。关键词:PyInstaller、Conda
Python程序的「加密」。关键词:Cython- 更早前的,Python机器学习包。关键词:scikit-learn、pandas、numpy
- 更早前的,Python实现的网页爬虫。关键词:selenium
Python程序的「加密」
我们的Python程序是随着客户端一同发布的,为了防止Python源码泄漏,我们通过Cython对核心代码编译后再进行打包发布,以提高窃取/破解的难度或门槛。
什么是Cython
Cython可以将Python程序转化为C/C++语言,并编译为动态库文件(在Windows上为.pyd,在Mac/Linux上为.so),可以支持与C/C++库的调用,可以进一步加速Python程序。因此,Cython本质目的其实并不是做加密,只是经过编译后的Python程序的破解提高了一个难度。
Cython 3.0 中文文档
Cython用法之setup脚本
安装Cython
pip install Cython
编译前
一个简单的例子,目录结构如下:
.
├── hello.py
├── lib
│ └── utils.py
├── main.py
└── setup.py
main.py
是主程序,导入了一个hello模块
# main.py
from hello import sayhello
sayhello('Kw')
hello.py
也引用了一个/lib/utils.py
,用于测试Cython是否会自动检索引用关系并编译引用模块,以及测试编译结果的输出目录结构。
# hello.py
from lib.utils import saywelcome
def sayhello(name):
print(f'hello {name}')
saywelcome(name)
/lib/utils.py
:
# /lib/utils.py
def saywelcome(name):
print(f'welcome {name}')
setup.py
是Cython执行脚本,指定了要编译的hello.py
以及lib/utils.py
。
# setup.py
from distutils.core import setup
from Cython.Build import cythonize
setup(
ext_modules = cythonize([
"hello.py",
"lib/utils.py",
]),
)
main.py是入口文件,需要被python解释器调用,因此入口文件是不进行编译的。核心代码应该模块化。
运行setup.py
执行编译;其中--inplace
令生成的编译文件在当前目录位置:
python3 setup.py build_ext --inplace
编译后
编译完成后的目录结构:
.
├── hello.c
├── hello.cpython-311-darwin.so
├── hello.py
├── lib
│ ├── utils.c
│ └── utils.py
├── main.py
├── setup.py
└── utils.cpython-311-darwin.so
可以看到,在需编译的.py文件同级目录处生成了同名的.c文件,但所有编译后的.so文件均位于构建脚本执行目录,而非源文件所在处。如,utils.xxx.so
并没有直接生成在/lib
目录下。
为了验证main.py
可以正确使用.so以代替.py文件,删除无关的.c/.py文件,并将utils.so
移动至/lib
下,目录结构如下:
.
├── hello.cpython-311-darwin.so
├── lib
│ └── utils.cpython-311-darwin.so
└── main.py
utils.cpython-311-darwin.so 命名中「cpython-311-darwin」部分无需调整。
执行主程序:python3 main.py
,正确输出:
hello Kw
welcome Kw
结论
- Cython不会自动检索Python代码的包引用关系,需显示指定每个待编译.py文件。
- 输出的.so/.pyd文件不会保持原有目录结构,需自行处理。
- 基于结论1和2,需要编写脚本自动编译项目下的所有.py,并保持目录结构。一个思路是先复制整个项目目录文件,用内联的方式在对应位置编译.so/.pyd,以保留目录结构(保留引用关系),最后再删除无用.py/.c(防止源码泄漏)。
Cython用法之cythonize命令
适用于编译单个.py,生成.so。
cythonize -i hello.py
由于前文关于「Cython的结论3」,可以利用该命令编写一个脚本,实现保持工程目录结构的自动编译。见[TODO,还没写]
Cython编译引入的类型转换问题
这个问题其实是撰写本文的初衷
在编译实际Python项目时遇到一个问题:一个模块方法在编译前(.py)源码可以执行,但在编译后(.so)执行异常。提示TypeError: Expected list, got numpy.ndarray
。
问题描述
问题简化描述为:
/lib/utils.py
里有一个将list转化为numpy数组的方法:
# /lib/utils.py
import numpy as np
def list2ndarray(vertices: list):
vertices = np.array(vertices)
# 为何不直接 return np.array(vertices)?
# 因为此处是简化的代码,转化后的vertices在实际项目中有其他使用。
return vertices
在main.py
中调用时,顺利执行;
# main.py
from lib.utils import list2ndarray
a = list2ndarray([4,5,6])
print(f'{type(a)}, {a}') # 输出 <class 'numpy.ndarray'>, [4 5 6]
但将(utils.py)编译为utils.so
后,执行异常。错误定位在utils.py
的这一行代码:
def list2ndarray(vertices: list):
vertices = np.array(vertices) # 这一行抛出错误 TypeError: Expected list, got numpy.ndarray
return vertices
排查思路
- 一开始排查方向是numpy的类型转化,通过搜索引擎以及AI查询
TypeError: Expected list, got numpy.ndarray
相关错误,引导了几个方向:numpy版本问题、输入参数类型问题等。 - 通过调试确认输入参数在编译前后没有变化,明确是list类型。代码是在编译后发生异常,可以确认是Cython引入的问题。
- 多番尝试(花了一上午),确认是Cython与Python在隐式转换方面的不同导致的。
原因具体是:
def list2ndarray(vertices: list):
声明了vertices是list类型,在传入np.array(vertices)
后是可以被正确转化的,但返回值是ndarray类型;此时在将该返回值(ndarray类型)赋值给vertices变量(声明为list类型)时,Python完成了隐式转换,而Cython环境下认为非法TypeError: Expected list, got numpy.ndarray
:因为对于vertices变量而言,它预期接收list,但却被给予了ndarray。
问题代码换一种写法的话,会使得该问题更清晰:
def list2ndarray(list_value: list): # list_value 是 list类型
ndarray_value = np.array(list_value) # ndarray_value 是 ndarray类型
list_value = ndarray_value # Cython下,无法将ndarray_value隐式转化为list_value,因此抛出异常
return list_value
进一步查询相关资料,得到确认:
Cython的类型声明严格性,而纯Python代码具有动态类型特性,允许隐式转换。
解决方案
确认问题后,解决方案就简单了。
第一种:不声明入参类型,允许隐式转换;但此举难免有点“暧昧不亲”。
def list2ndarray(vertices: list): # 改为
def list2ndarray(vertices): # 不声明list类型
第二种:避免所谓的“动态类型”,使用正确类型的变量:
def list2ndarray(list_value: list):
ndarray_value = np.array(list_value)
# list_value = ndarray_value # 避免所谓的“动态类型”
return ndarray_value # 使用正确的变量
问题得解。
反思
其实,这个问题就是一个馒头引发的血案,花了一个早上时间排查,值得记录一下。
在Python或JS这类「弱类型、支持动态类型」的编程环境中,没有了类型的束缚,代码写起来非常自由自在,我也很喜欢这种感觉。但是,这就要求Coder对数据的流转了然于心,否则这种小问题很隐晦,可能需要排查半天。反观Java等强类型编程语言里,这种错误编译器都直接提醒你了。
另外,AI确实能提供解决思路,但具体解决方案还是要看人。如果我没有同时做过Java/JS/Python的开发,在这个问题上AI的回答可能让人云里雾里。