图片

世事如茶,初入喉肠涩且苦,再回味时甘且甜

图片
图片

本文从易到难,简单梳理了.so文件编译的几种方案:最原始的编译方法、不删除中间文件的编译方法和相对集成的编译方案。推荐直接食用第三种集成方案。

相关代码已开源,仓库地址:https://github.com/itlubber/py2so

    背景简介    
python 是一种面向对象的解释型计算机程序设计语言,具有丰富和强大的库,使用其开发产品快速高效。
python 的解释特性是将py编译为独有的二进制编码 pyc 文件,然后对 pyc 中的指令进行解释执行,但是 pyc 的反编译却非常简单,可直接反编译为源码。
当项目相关代码需要发布到外部环境时,如果需要保证源代码不被泄漏或反编译破解,就需要对 python 代码进行加密,以保证源码的安全性。
下面本文将介绍一种最常用的源码反编译加密方式:将 .py 文件编译为 .so 文件,实现源代码加密的同时,还能加速代码的运行速度。
   编译环境   
so文件 编译主要通过 cython 库实现,而 cython 库又依赖于 C 的环境,所以在编译之前,需要保证服务器或者本地环境中有相关的环境。本文默认使用 linux 环境进行编译,windows 环境的编译基本暂不涉及。
通常如果通过 anaconda 安装的 python 基本上环境都可以直接编译打包,如果中途报错可以再回头来安装相应的环境或包。
如果服务器没有环境,可以先通过 linux 的包管理器安装 python-devel 和 gcc,再通过 python 的包管理器 pip 或 pip3 安装 cython 库。如果服务器上已安装 python-devel 和 gcc,直接通过 pip 安装 cython 即可。
如果安装过程中报错没有权限,请使用 sudo 进行提权。
01
 ubuntu 环境准备
apt-get install python-devel
apt-get install gcc
pip install cython
02
 centos 环境准备
yum install python-devel
yum install gcc
pip install cython
   编译源码   
01
 编译常见问题
结合博主在编译过程中遇到的问题,大致说下解决方案:
(1) 编译过程中语法报错,且源码不能正常执行:
先把源码 debug 通了再来吧,求求了。
(2) 编译过程中语法报错,但源码能正常执行:
编译时不支持某些代码写法,不要偷懒,找到报错那行代码,重新改下写法就行,cython 编译还是有局限滴,支持不了辣么多骚操作。
(3) 执行完编译后,发现项目中还有中间文件存在:
建议一直守着编译完成,很多时候编译完了,但中间某些地方有问题,程序直接退出了,编译程序还执行到删除中间文件那一步,但日志没有显示全,你以为你编译完了,实际还没有。
(4) 编译完成后,本地运行没问题,迁移到服务器上无法运行:
编译后的 so文件 只能在与编译时相同的 python版本 和 编译平台上运行。例如在 ubuntu 18.04 LTS 上使用 python 3.8 编译完成的项目,迁移到 ubuntu 18.04 LTS 上使用 python 3.6 执行或者迁移到 centos 7 上使用 python 3.8执行都是无法执行的。
02
 项目结构
图片
为了能更好的说明示例项目中各文件的作用及内容,下面对项目下的每个文件进行说明:
  • main.py
# -*- coding: utf-8 -*-
'''
@Time : 2022/8/23 13:12
@Author : itlubber
@Site :
'''

# import sys
# sys.path.append('build/lib.macosx-10.9-x86_64-3.8') # 将编译后的包路径添加到python的包路径中

from example.your_codes import itlubber_py2so # 导入需要的包

def read_data(file):
    '''
    读取文件中的内容
    '''

    with open(file, 'r') as f:
        return f.readline()

if __name__ == '__main__':
    msg = read_data('example/README.md') # 读取非 python 文件中的内容
    print(itlubber_py2so(msg)) # 传入文件内容, 打印 itlubber_py2so 返回的结果

  • example/your_codes.py

# -*- coding: utf-8 -*-
'''
@Time : 2022/8/23 13:12
@Author : itlubber
@Site :
'''


def itlubber_py2so(msg):
    '''
    根据传入 msg, 返回处理后的结果
    '''

    return 'py2so : {}'.format(msg)
  • example/README.md
https:///python-py2so

执行 main.py  主程序的结果如下:

图片
03
 原始编译方案
将示例项目 example 包下的 your_codes.py 编译为 so文件,并在主程序 main.py 中使用。
原始的编译方案编译过程中产生的中间文件需要手工进行清除,最终编译完成后文件夹名称依赖于编译时所使用的 python 版本和编译的平台,只能编译设定的 .py 文件,其他文件需要手动复制过去或者重新设定python的包路径。

(1) 在 main.py 同级目录下方新建 setup.py 文件,具体内容如下:

from distutils.core import setup
from Cython.Build import cythonize


# 程序执行命令: python setup.py build_ext
setup(ext_modules = cythonize(['example/your_codes.py']))

(2) 执行编译命令

python setup.py build_ext
图片

(3) 编译结果

图片

(4) 编译测试

将 main.py 复制到编译后的 example 同级目录,将 example/README.md 复制到编译后的 example 目录下方。完成复制后执行 main.py 文件。
或者
在 main.py 中添加编译后的包路径后执行 main.py 脚本。为了方便本文采用添加包路径进行测试。修改 main.py 中的内容如下:
import sys
sys.path.append('build/lib.macosx-10.9-x86_64-3.8') # 将编译后的包路径添加到python的包路径中


from example.your_codes import itlubber_py2so # 导入需要的包


def read_data(file):
    '''
    读取文件中的内容
    '''

    with open(file, 'r') as f:
        return f.readline()


if __name__ == '__main__':
    msg = read_data('example/README.md') # 读取非 python 文件中的内容
    print(itlubber_py2so(msg)) # 传入文件内容, 打印 itlubber_py2so 返回的结果

执行 main.py 可以发现执行结果与未编译前的结果一致,即编译成功。

图片
03

 半集成编译方案

半集成方案需要需要一定的代码阅读能力和修改能力,在脚本中自定义需要编译和不需要编译的 .py 文件,以及除 python 文件外不需要被复制的文件。具体内容如下:
# -*- coding: utf-8 -*-
'''
@Time : 2022/8/23 13:12
@Author : itlubber
@Site :
@File : py2so.py
'''

import os
import sys
import time
import shutil
from distutils.core import setup
from Cython.Build import cythonize

currdir = os.path.abspath('.')
build_dir = 'build'
build_tmp_dir = build_dir '/temp'

def build_list(basepath, name='', excepts=[], copyOther=False, delC=False, starttime=None):
    '''
    获取py文件的路径
    :param basepath: 根路径
    :param name: 文件/夹
    :param copy: 是否copy其他文件
    :param excepts: 不拷贝的文件
    :return: py文件的迭代器
    '''

    excepts_files = [os.path.join(currdir, f) for f in excepts]
    fullpath = os.path.join(basepath, name)
    for fname in os.listdir(fullpath):
        ffile = os.path.join(fullpath, fname)
        
        if os.path.isdir(ffile) and fname != build_dir and not fname.startswith('.'):
            for f in build_list(basepath, name=fname, copyOther=copyOther, delC=delC, starttime=starttime):
                yield f
                
        elif os.path.isfile(ffile):
            ext = os.path.splitext(fname)[1]
            if ext == '.c':
                if delC and os.stat(ffile).st_mtime > starttime:
                    os.remove(ffile)
            elif os.path.splitext(fname)[1] not in('.pyc', '.pyx'):
                if os.path.splitext(fname)[1] in ('.py', '.pyx') and not fname.startswith('__'):
                    yield os.path.join(name, fname)
                elif ffile not in excepts_files and copyOther:
                    dstdir = os.path.join(basepath, build_dir, name)
                    if not os.path.isdir(dstdir):
                        os.makedirs(dstdir)
                    shutil.copyfile(ffile, os.path.join(dstdir, fname))
        else:
            pass
        
        
def copy_file(source):
    dist_dir = os.path.join(currdir, build_dir, source)
    source_dir = os.path.join(currdir, source)
    
    if not os.path.isdir(source_dir):
        if not os.path.exists(os.path.dirname(dist_dir)):
            os.makedirs(os.path.dirname(dist_dir))
    
        shutil.copyfile(source_dir, dist_dir)
    else:
        for file in os.listdir(source_dir):
            copy_file(os.path.join(source_dir, file))

if __name__ == '__main__':
    starttime=time.time()
    
    # 不编译的 python 文件
    excepts_build = ['py2so.py', 'main.py']
    # 不复制到编译后的非 python 文件
    excepts_files = ['README.md', 'LICENSE', '.gitignore', 'setup.py']

    # 编译 python 文件
    module_list = list(build_list(currdir, starttime=starttime))
    module_list = [py for py in module_list if py not in excepts_build]
    setup(ext_modules = cythonize(module_list), script_args=['build_ext', '-b', build_dir, '-t', build_tmp_dir])

    # 拷贝其他文件
    list(build_list(currdir, excepts=excepts_files, copyOther=True, starttime=starttime))
    
    # 拷贝不编译的py文件
    for file in excepts_build:
        copy_file(file)

    # 删除编译产生的中间文件
    module_list = list(build_list(currdir, delC=True, starttime=starttime))
    if os.path.exists(build_tmp_dir):
        shutil.rmtree(build_tmp_dir)

    print ('complate! time:', time.time()-starttime, 's')

运行脚本编译的到的项目文件在 build 下方,编译完成后中间文件全部删除,且非 .py 文件也会复制到对应位置。
python py2so.py

执行 build 目录下方的 main.py 可以发现执行结果与未编译前的结果一致,即编译成功:

图片
03

 集成编译方案

集成方案将半集成方案中需要修改的部分内容改为命令行参数传入,使用者可以不用频繁修改代码来编译 so文件 ,非常方便,但有部分 bug 待修复 QaQ ,使用时需要把脚本放置在整个项目文件夹的上级目录而非同级目录(后续有时间会修复这个 bug,感觉还是同级目录方便;同时结果文件存放的路径不能为 build,这个后续会默认放 build,暂时先这样吧 QaQ)
# -*- coding: utf-8 -*-
'''
@Time : 2022/8/23 13:12
@Author : itlubber
@Site :
@File : py2so_ensemble.py
'''

import os
import sys
import shutil
import argparse
import platform
from distutils.core import setup
from Cython.Build import cythonize
from distutils.extension import Extension

def getfiles_inpath(dir_path, includeSubfolder=True, path_type=0, ext_names='*'):
    '''
        获得指定目录下的所有文件,
        :param dir_path: 指定的目录路径
        :param includeSubfolder: 是否包含子文件夹里的文件,默认 True
        :param path_type: 返回的文件路径形式
            0 绝对路径,默认值
            1 相对路径
            2 文件名
        :param ext_names: '*' | string | list
            可以指定文件扩展名类型,支持以列表形式指定多个扩展名。默认为 '*',即所有扩展名。
            举例:'.txt' 或 ['.jpg','.png']
        :return: 以 yield 方式返回结果
    '''

    if type(ext_names) is str:
        if ext_names != '*':
            ext_names = [ext_names]
    if type(ext_names) is list:
        for i in range(len(ext_names)):
            ext_names[i] = ext_names[i].lower()

    def keep_file_byextname(file_name):
        if type(ext_names) is list:
            if file_name[0] == '.':
                file_ext = file_name
            else:
                file_ext = os.path.splitext(file_name)[1]
            #
            if file_ext.lower() not in ext_names:
                return False
        else:
            return True
        return True

    if includeSubfolder:
        len_of_inpath = len(dir_path)
        for root, dirs, files in os.walk(dir_path):
            for file_name in files:
                if not keep_file_byextname(file_name):
                    continue
                if path_type == 0:
                    yield os.path.join(root, file_name)
                elif path_type == 1:
                    yield os.path.join(
                        root[len_of_inpath:].lstrip(os.path.sep), file_name)
                else:
                    yield file_name
    else:
        for file_name in os.listdir(dir_path):
            filepath = os.path.join(dir_path, file_name)
            if os.path.isfile(filepath):
                if not keep_file_byextname(file_name):
                    continue
                if path_type == 0:
                    yield filepath
                else:
                    yield file_name

def make_dir(dirpath):
    '''
    创建目录,支持多级目录,若目录已存在自动忽略
    '''

    dirpath = dirpath.strip().rstrip(os.path.sep)

    if dirpath:
        if not os.path.exists(dirpath):
            os.makedirs(dirpath)

def get_encfile_list(opts):
    will_compile_files = []
    if opts.directory:
        if not os.path.exists(opts.directory):
            print('No such Directory, please check or use the Absolute Path')
            sys.exit(1)

        pyfiles = getfiles_inpath(dir_path=opts.directory,
                                 includeSubfolder=True,
                                 path_type=1,
                                 ext_names='.py')
        # ignore __init__.py file
        pyfiles = [pyfile for pyfile in pyfiles if not pyfile.endswith('__init__.py')]
        # filter maintain files
        opts.excludeFiles = []
        if opts.ignore:
            for path_assign in opts.ignore.split(','):
                if not path_assign[-1:] in ['/', '\']: # if last char is not a path sep, consider it's assign a file
                    opts.excludeFiles.append(path_assign)
                else:
                    assign_dir = path_assign.strip('/').strip('\')
                    tmp_dir = os.path.join(opts.rootName, assign_dir)
                    files = getfiles_inpath(dir_path=tmp_dir,
                                           includeSubfolder=True,
                                           path_type=1)
                    for file in files:
                        fpath = os.path.join(assign_dir, file)
                        opts.excludeFiles.append(fpath)

        tmp_files = list(set(pyfiles) - set(opts.excludeFiles))
        will_compile_files = []
        for file in tmp_files:
            will_compile_files.append(os.path.join(opts.directory, file))

    elif opts.file:
        if opts.file.endswith('.py'):
            will_compile_files.append(opts.file)
        else:
            print('Make sure you give the right name of py file')

    else:
        print('no -f or -d param')
        sys.exit()

    return will_compile_files

def clear_builds(opts):
    if os.path.isdir('build'):
        shutil.rmtree('build')
    if os.path.isdir('tmp_build'):
        shutil.rmtree('tmp_build')
    if os.path.isdir(opts.output):
        shutil.rmtree(opts.output)

def clear_tmps(opts):
    if os.path.isdir('build') and opts.output != 'build':
        shutil.rmtree('build')
    if os.path.isdir('tmp_build') and opts.output != 'tmp_build':
        shutil.rmtree('tmp_build')

def pyencrypt(files):
    extentions = []
    print(files)
    for full_filename in files:
        filename = full_filename[:-3].replace(os.path.sep, '.')
        extention = Extension(filename, [full_filename])
        extention.cython_c_in_temp = True
        extentions.append(extention)
    setup(
        script_args=['build_ext'],
        ext_modules=cythonize(extentions, quiet=False, language_level=3, nthreads=1, build_dir='tmp_build'),
    )

def gen_project(opts, will_compile_files):
    make_dir(opts.output)
    for file in getfiles_inpath('build', True, 1, ['.so', '.pyd']):
        src_path = os.path.join('build', file)
        mid_path = os.path.sep.join(file.split(os.path.sep)[1:-1])
        file_name_parts = os.path.basename(src_path).split('.')
        file_name = '.'.join([file_name_parts[0]] file_name_parts[-1:])
        dest_path = os.path.join(opts.output, mid_path, file_name)
        make_dir(os.path.dirname(dest_path))
        shutil.copy(src_path, dest_path)
    
    # 非编译文件拷贝至结果路径
    not_compile_files = get_not_compile_files(opts, will_compile_files)
    for not_compile_file in not_compile_files:
        dest_path = os.path.join(opts.output, not_compile_file)
        filepath, filename = os.path.split(dest_path)
        make_dir(filepath)
        shutil.copyfile(not_compile_file, dest_path)

    if opts.remove:
        clear_tmps(opts)
    
    print('py2so encrypt build complate.')

def get_not_compile_files(opts, will_compile_files):
    '''
    获取非编译文件
    '''

    files = getfiles_inpath(dir_path=opts.directory,
                           includeSubfolder=True,
                           path_type=1,
                           ext_names='*')
    files = [os.path.join(opts.directory, file) for file in files if not file.endswith('.pyc')]
    not_compile_files = list(set(files) - set(will_compile_files))
    return not_compile_files

if __name__ == '__main__':
    # 不支持 windows 系统编译
    if platform.system() == 'Windows':
        print('只支持linux,windows下可以使用pyinstaller打包exe')
        sys.exit()
    
    # 编译参数
    parser = argparse.ArgumentParser(description='py2so options:')
    
    # -d 和 -f 二选一
    exptypegroup = parser.add_mutually_exclusive_group()
    exptypegroup.add_argument('-f', '--file', help='python文件 (如果使用-f, 将编译单个python文件)', default='')
    exptypegroup.add_argument('-d', '--directory', help='python项目路径 (如果使用-d参数, 将编译整个python项目)', default='')
    
    parser.add_argument('-o', '--output', help='编译完成后整个项目输出的文件路径', default='itlubber_py2so')
    parser.add_argument('-i', '--ignore', help='''标记你不想编译的文件或文件夹路径。注意: 文件夹需要以路径分隔符号(`/`或`\`,依据系统而定)结尾,并且需要和-d参数一起使用。例: -i main.py,mod/__init__.py,exclude_dir/''')
    parser.add_argument('-r', '--remove', help='清除所有中间文件,只保留加密结果文件,默认True', action='store_true', default=True)
    opts = parser.parse_args()
    
    # 获取所有待编译 python 文件
    will_compile_files = get_encfile_list(opts)
    
    # 清空上一次运行生成的临时文件
    clear_builds(opts)
    
    # 编译 python 文件
    pyencrypt(will_compile_files)
    
    # 将编译好的工程输出到结果文件夹
    gen_project(opts, will_compile_files)

执行 itlubber_py2so 目录下方的 main.py 可以发现执行结果,理论上与编译前结果应该一致QAQ图片
   本文参考   
  1. https://github.com/sixgad/py2so
  2. https://github.com/ArvinMei/py2so
  3. https://github.com/itlubber/itlubber_py/blob/main/itlubber/py2so.py
  4. https://github.com/itlubber/itlubber_py/blob/main/itlubber/py2so_signal.py

图片