Python程序的「加密」:Cython编译

目录

  • 背景
  • Python程序的「加密」
  • 什么是Cython
  • Cython用法之setup脚本
    • 安装Cython
    • 编译前
    • 编译后
    • 结论
  • Cython用法之cythonize命令
    • cythonize命令
    • 模块化编译脚本
  • Cython编译引入的类型转换问题
    • 问题描述
    • 排查思路
    • 解决方案
    • 反思

背景

近一年来在Python方面做了不少事情:最早接触Python是利用selenium写了一个网页爬虫。2024年上半年利用scikit-learn做了机器学习方面的入门实践;下半年开始接触UE5、Blender等3D建模、视觉相关工具的脚本开发,这些工具都提供Python的开发接口。通过Python学到了不少新知识,感触最深的就是,Python太方便了。有太多太多的库可以被集成调用,几乎除了业务逻辑,技术实现都可以是拿来主义。

好久没有发coding的博文了,最近准备把关于Python的相关实践总结下,主要记录实现方案、遇到的问题和解决思路。
涉及的话题如下,想道哪写到哪。

  1. UE引擎的Python脚本开发,实现批量全自动的渲染队列,并加入通知报警等功能。关键词:UE、ndisplay、渲染队列。
  2. Blender的Python脚本开发,实现自定义的模型生成。关键词:obj文件类型、uv展开、用shapely对二维几何图形的计算、rectpack二维矩形打包问题、pillow图像生成。
  3. Python程序的打包。关键词:PyInstaller、Conda
  4. Python程序的「加密」。关键词:Cython
  5. 更早前的,Python机器学习包。关键词:scikit-learn、pandas、numpy
  6. 更早前的,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

编译前

一个简单的例子,目录结构如下:

cython_demo
├── 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

编译后

编译完成后的目录结构:

cython_demo
├── 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下,目录结构如下:

cython_demo
├── 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

结论

  1. Cython不会自动检索Python代码的包引用关系,需显示指定每个待编译.py文件。
  2. 输出的.so/.pyd文件不会保持原有目录结构,需自行处理。

Cython用法之cythonize命令

cythonize命令

适用于编译单个.py,生成.so。

cythonize -i hello.py

模块化编译脚本

基于前文关于Cython结论,为了实现中大型项目的编译,需要编写脚本自动编译项目下的所有.py,并保持目录结构。一个实现思路是:先复制整个项目目录所有文件,利用cythonize -i内联的方式在.py对应位置编译出.so/.pyd,以保留目录结构(得以保留引用关系),最后再删除无用.py/.c(得以防止源码泄漏)。

脚本如下,可直接执行,得到与setup脚本一致的结果。

# cython_utils.py
import shutil
import os
import subprocess

def cythonize(src_path, dest_path, exclude_files=[], clear_build_files=True):
    if not os.path.exists(src_path):
        raise ValueError(f'src path {src_path} not exists')
    
    # 复制源目录,保留目录结构
    shutil.rmtree(dest_path, ignore_errors=True)
    shutil.copytree(src_path, dest_path)
    
    # 编译.py文件
    py_files = find_py_files(dest_path)
    for file in py_files:
        # 不编译的文件
        if is_excluded(file, exclude_files):
            continue

        # cythonize进行编译
        cmd = ["cythonize", "-i", file]
        try:
            subprocess.run(cmd, check=True)
        except subprocess.CalledProcessError as e:
            raise ValueError(f'cythonize failed : {str(e)}')

    # 删除中间文件以及.py源码文件
    if clear_build_files: clear_build(dest_path, exclude_files=exclude_files)

def find_py_files(dir_path, ext = '.py'):
    py_files = []

    for item in os.listdir(dir_path):
        item_path = os.path.join(dir_path, item)
        if os.path.isfile(item_path):
            if item.endswith(ext): py_files.append(item_path)
        elif os.path.isdir(item_path):
            py_files.extend(find_py_files(item_path))

    return py_files

def is_excluded(file_path, exclude_files=[]):
    for exclude in exclude_files:
        if file_path.endswith(exclude):
            return True
        
    return False

def clear_build(dir_path, clear_file_exts=['.c', '.py'], clear_dirs=['__pycache__', 'build'], exclude_files=[]):
    for item in os.listdir(dir_path):
        item_path = os.path.join(dir_path, item)

        if os.path.isfile(item_path):
            _, ext = os.path.splitext(item_path)
            if ext in clear_file_exts and not is_excluded(item_path, exclude_files):
                os.remove(item_path)
        elif os.path.isdir(item_path):
            if item in clear_dirs:
                shutil.rmtree(item_path)
            else:
                clear_build(item_path, clear_file_exts, clear_dirs, exclude_files)

if __name__ == "__main__":
    # 环境要求安装cythonize:cythonize -h
    
    exclude_files = ['__init__.py', '__main__.py', 'setup.py', 'main.py'] # 保留py模块定义文件不编译
    cythonize('./cython_demo', './build/cython_demo', exclude_files)

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}')  # 输出 , [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

排查思路

  1. 一开始排查方向是numpy的类型转化,通过搜索引擎以及AI查询TypeError: Expected list, got numpy.ndarray相关错误,引导了几个方向:numpy版本问题、输入参数类型问题等。
  2. 通过调试确认输入参数在编译前后没有变化,明确是list类型。代码是在编译后发生异常,可以确认是Cython引入的问题。
  3. 多番尝试(花了一上午),确认是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的回答可能让人云里雾里。

你可能感兴趣的:(python,python,Cython)