尝试利用Cython将Python项目转化为单个.so

2020-03-06 6,478

Cython(https://cython.org)是一种方便开发者为PythonC extensions的语言,降低了开发者写C拓展的难度;Cython module可以是.py或者.pyx文件;

编译Cython module的主要过程:Cython compiler.py/.pyx文件编译为c/c++文件;C compiler再将c/c++编译为.so(windows .pyd)

通过Cython.py转化为动态共享库来发布,不仅能够获得性能的提升,从安全的角度来看,还能有助于保护源码;

1Cython的基本用法

编写测试代码hello.py:

def say_hello_to(name):

print("Hello %s!" % name)

相同目录下新建setup.py:

from distutils.core import setup

from Cython.Build import cythonize

 

setup(name='Hello world',

      ext_modules=cythonize("hello.py"))

编译hello.py

image.png 

import使用生成的hello module

image.png 

2、分析Cython编译生成的hello.c

2.1 initialization functionmodule入口函数)

hello.c其实是C/C++ extension,首先,发现hello.c中存在initialization function

image.png 

image.png 

image.png 

A.python版本>=3,入口函数名字为:PyInit_##modulename

否则,为init##modulename

B.python版本>=3,并且开启了PEP489_MULTI_PHASE_INIT时,入口函数返回了PyModuleDef的对象指针;并且定义了pymod_exec函数;

否则,入口函数的函数体就是pymod_exec函数,返回初始化完成的Module

Python官方文档Building C and C++ Extensions介绍了入口函数的命名规则;

C.分析发现入口函数在import过程中被PyImport_LoadDynamicModuleWithSpec()调用:

image.png 

2.2 PEP 489:Multi-phase extension module initialization

PEP489重新设计了extension moduleimport机制的交互过程,提出了多阶段初始化,并且向后兼容single-phase initialization

在入口函数之后,extension module的创建被分成了两个阶段:

module creation phase,module execution phase

A. 入口函数

PyModuleDef对象进行初始化,并返回对象指针;

image.png 

__pyx_moduledef的类型就是PyModuleDef,结构如下:

注意到第二个成员即是module name

image.png 

image.png 

extension module两个阶段处理过程就定义在PyModuleDefm_slots成员中;

B.module creation phase

Py_mod_create slot管理,value所指向的函数有如下签名:

PyObject* (*PyModuleCreateFunction)(PyObject *spec, PyModuleDef *def)

创建并返回一个Module Object,第一个参数是ModuleSpec类型指针,在PEP451中定义;

上述例子相对应的函数如下:

image.png 

C.module execution phase

Py_mod_exec slot指定,value所指向的函数有如下签名:

int (*PyModuleExecFunction)(PyObject* module)

完成Module的初始化工作;

上述例子相对应的函数为:__pyx_pymod_exec_hello

image.png 

2.1中也提到single-phase initialization情况下,__pyx_pymod_exec_hello就是入口函数;

如果是multi-phase,直接使用module creation phase创建的module进行后续的初始化;

python版本小于3,调用Py_InitModule4()生成module,第一个参数是module name

否则,调用PyModule_Create(),参数为前面PyModuleDef对象;

image.png 

后续module初始化完成后,会将此module加入sys.modules中,并且以module namekey

image.png 

3、多个Cython Module转化成单个.so

   Cython将单个.py转化为单个.so比较方便,但是对package的支持却不够;package中存在多个.py和子目录,其子目录里面又包含多个.py和子目录;这种情况下将每个.py转化为一个.so,不便于后续对.so的加固保护。那么如何将package编译成一个.so

3.1 PEP 302New Import Hooks

  参考Collapse multiple submodules to one Cython extension,第二个回答提到import一个模块时,Python会通过遍历sys.meta_path中的finder来确定一个module相对应的loaderimport机制在PEP 302中引入了sys.meta_pathfinderloader

  sys.meta_path中注入module定制化的finderfinder需要实现find_module(),返回module定制的loader对象;而loader需要实现load_module(),完成对模块的导入,并且返回module对象;

3.2 imp.load_dynamic()

   参考的第二个答案基于importlib给出了python3的实现,在python2中,importlib并没有“MetaPathFinder”等类,不过python2中提供了imp.load_dynamic(name, pathname[, file])从动态库里来初始化module,且返回module对象;imp.load_dynamic官方文档中有如下说明:

A. The pathname argument must point to the shared library. The name argument is used to construct the name of the initialization function: an external C function called initname() in the shared library is called. The optional file argument is ignored.

  也就是说该函数有三个参数:module的名称name,动态库的路径pathname,以及1个可忽略的参数file

B. CPython implementation detail: The import internals identify extension modules by filename, so doing foo = load_dynamic("foo", "mod.so") and bar = load_dynamic("bar", "mod.so") will result in both foo and bar referring to the same module, regardless of whether or not mod.so exports an initbar function.

   分析imp.load_dynamic()源码发现:

   Python2中维持了一个dictionary:extensions,以pathnamekey,用来记录已经加载的动态库,作用就是防止多次加载同一个动态库执行其中的入口函数;imp.load_dynamic会调用_PyImport_LoadDynamicModule()_PyImport_LoadDynamicModule()会调用_PyImport_FindExtension()来查询extensions中缓存的pathname相对应的module;如存在,就不会调用入口函数。

   此外还发现,file参数存在的情况下,imp.load_dynamic会取得file的文件描述符fp,进而确定该文件的设备和inode编号,若已加载过该动态库文件,最终会通过dlsym(filehandlefuncname)查找入口函数,并返回入口函数指针;

image.png 

image.png 

image.png 

所以,不同的module,调用imp.load_dynamic()时,设置好file参数,pathname保持不同,设置为module的完整名即可,就可实现从同一个so中加载不同的module

3.3 module名称的调整

   2.1节的hello.py,其python2入口函数名为:inithellopackage中同一目录下,python文件名不同,所以能够保证入口函数名称不同;但如果其子目录下面存在同名的python文件,就会导致入口函数名冲突。

如下面的例子:
image.png

foo/foo1.pyfoo/bar/foo1.py就会冲突,都是initfoo

为了解决此问题,我们可以利用module包含package的完整名来重命名入口函数,将完整名中的点“.”换成下划线“_”;这样入口函数分别变为:initfoo_foo1initfoo_bar_foo1,对于import机制来说module名就变为:foo_foo1foo_bar_foo1module完整名就分别变成:foo.foo_foo1foo.bar.foo_bar_foo1

3.4 python2下的实现

   基于Collapse multiple submodules to one Cython extension的第二个回答,我们对Cythonbootstrap.py做了如下修改;

3.4.1 Compiler/ModuleNode.py的修改

Cython Compiler的分析,发现2.1hello.c代码生成部分由ModuleNode.py负责;

A. 入口函数名调整为下划线形式

image.png 

ModuleNode.py对应的部分调整:

image.png 

B. __pyx_pymod_exec_hello()single-phase initialization情况下的入口函数函数体

image.png 

ModuleNode.py对应的部分调整:

image.png 

3.4.2 bootstrap.py/setup.py

根据前面3.2bootstrap.py如下:

import sys, imp

 

class CythonPackageFileLoader():

    def __init__(self):

        pass

    def load_module(self, fullname):

        print('load_module: '+fullname)

        sub_name = fullname.replace('.', '_')

        package = fullname.rsplit('.', 1)[0]

        new_name = package + '.' + sub_name

 

        if new_name in sys.modules:

            print('found in sys.modules')

            return sys.modules[new_name]

 

        module = imp.load_dynamic(new_name, new_name, file(__file__))

        module.__file__ = __file__

        module.__loader__ = self

        module.__package__ = package

        print(module)

        #print(sys.modules.keys())

        #sys.modules[new_name] = module

        return module

 

class CythonPackageMetaPathFinder():

    def __init__(self, name_filters):

        self.name_filters = name_filters

        self.loader = CythonPackageFileLoader()

 

    def find_module(self, fullname, path=None):

        print('find_module: '+fullname)

        for name_filter in self.name_filters:

            if fullname == name_filter:

                return self.loader

        return None

 

def bootstrap_cython_submodules():

    sys.meta_path.append(CythonPackageMetaPathFinder(

        [

            'foo.foo1',

            'foo.foo2',

            'foo.bar.bar1',

            'foo.bar.foo1'

        ]

        ))

setup.py

#!/usr/bin/env python2

 

from distutils.core import setup

from distutils.extension import Extension

from Cython.Build import cythonize

 

extension = Extension("foo.foo_bootstrap",

        [

            "foo/bootstrap.py",

            "foo/foo1.py",

            "foo/foo2.py",

            "foo/bar/bar1.py",

            "foo/bar/foo1.py",

        ],

        extra_compile_args=['-DCYTHON_PEP489_MULTI_PHASE_INIT=0', '-g']

    )

 

setup(

    name = 'cython_test',

    ext_modules = cythonize(extension)

)

    

3.5 python3下的实现

3.5.1 PEP 451:A ModuleSpec Type for the Import System

PEP 451提出了向importlib.machinery添加一个名为“ModuleSpec”的新类。它将提供用于加载一个module的所有导入相关的信息,且无需首先加载module即可使用。finder将直接提供module对应的ModuleSpec对象,而不是loader(通过ModuleSpec间接提供)。import机制将进行调整以利用ModuleSpec的优势,使用它们来加载模块。

finderloader基于此PEP需要进行相应的调整:

1、定制module对应的finder,并且实现其find_spec(),返回对应的ModuleSpec对象,取代其find_module()

2、定制loader,尽可能实现其exec_module(),取代load_module()

3、当然,PEP 451向后兼容PEP 302

importlib里面新增了一些api和类,方便我们实现finderloader

A.通过importlib.machinery.ExtensionFileLoader(fullname, path)来实现loader

B.通过importlib.util.spec_from_loader(name, loader, *, origin=None, is_package=None)来生成封装了loaderspec

C.继承importlib.abc.MetaPathFinder实现finder,其find_spec()执行上述两步;

module name应该为下划线格式的新名字,且为包含package的完整名;

基于Collapse multiple submodules to one Cython extension的第二个回答,利用ModuleSpec,我们对Cythonbootstrap.py做了如下修改;

3.5.2 Compiler/ModuleNode.py的修改

A、入口函数修改为下划线格式

image.png 

ModuleNode.py对应的部分调整:

image.png 

BPyModuleDef类中的m_name

   PyModule_Create()源码中指出,m_name是不带packagemodule name

image.png

image.png 

ModuleNode.py对应的部分调整:

image.png 

C__pyx_pymod_exec_hello():校验及加入sys.modules

image.png 

image.png 

3.5.3 bootstrap.py/setup.py

bootstrap.py:

import sys

import importlib.abc

import importlib.util

 

class CythonPackageMetaPathFinder(importlib.abc.MetaPathFinder):

    def __init__(self, name_filters):

        super(CythonPackageMetaPathFinder, self).__init__()

        self.name_filters = name_filters

 

    def find_spec(self, fullname, path, target):

        print('find_spec: '+fullname)

        

        for name_filter in self.name_filters:

           if fullname==name_filter:

               # foo.foo1 -> foo.foo_foo1

               sub_name = fullname.replace('.', '_')

               new_name = fullname[:fullname.rfind('.')+1] + sub_name

               #print('new_name: '+new_name)

 

               if new_name in sys.modules:

                   return sys.modules[new_name].__spec__

 

               loader = importlib.machinery.ExtensionFileLoader(new_name,__file__)

            

               spec = importlib.util.spec_from_loader(new_name, loader)

               print(spec)

               return spec

        return None

 

# injecting custom finder/loaders into sys.meta_path:

def bootstrap_cython_submodules():

    print('in foo bootstrap '+__file__)

    name_filters = [

            'foo.foo1',

            'foo.foo2',

            'foo.bar.foo1',

            'foo.bar.bar1'

            ]

sys.meta_path.append(CythonPackageMetaPathFinder(name_filters))

setup.py: python2

参考:

https://docs.python.org/3/extending/building.html

https://github.com/python/cpython/blob/master/Python/importdl.c#L134

https://www.python.org/dev/peps/pep-0489/

https://stackoverflow.com/questions/30157363/collapse-multiple-submodules-to-one-cython-extension

https://www.python.org/dev/peps/pep-0302/

https://docs.python.org/2/library/imp.html#imp.load_dynamic

https://github.com/python/cpython/blob/2.7/Python/import.c#L3150

https://www.python.org/dev/peps/pep-0451/


本文作者:GalaxyLab

本文为安全脉搏专栏作者发布,转载请注明:https://www.secpulse.com/archives/124724.html

Tags:
评论  (0)
快来写下你的想法吧!

GalaxyLab

文章数:5 积分: 6

安全问答社区

安全问答社区

脉搏官方公众号

脉搏公众号