首页 > 技术文章 > PythonCookbook第十章(模块和包)修正版本

sidianok 2020-03-17 01:19 原文

2020年10月24日更新

记住一点,一个项目文档,跑的文件的那个路径,会自动添加到Python的环境变量,且为第一优先路径。

 

 10.1把模块按层次结构组成包

问题:

我们想把代码按照一定的层次结构组成包

解决方案:

创建一个包很简单,只要文件夹里面有__init__.py就可以了。创建有层次的包只要每个目录都定义了__init__.py就可以了。

└── graphics
    ├── __init__.py
    ├── formats
    │   ├── __init__.py
    │   ├── jpg.py
    │   └── pny.py
    └── primitive
        ├── __init__.py
        ├── fill.py
        ├── line.py
        └── text.py

 讨论:

上面的这样的文件结构,当一个脚本写下import graphics,文件graphics/__init__.py会被导入并形成graphics命名空间的内容。

当执行import graphics.formats.jpg 文件graphics/__init__.py和文件graphics/formats/__init__.py都会在导入文件graphics.formats.jpg之前优先导入

大多数情况下__init__.py文件留空就可以了。但是在某些特定的情况下__init__.py需要含有代码。

比如可以用__init__.py来自动加载子模块

绝大部分时候让__init__.py空着就好。但是有些情况下可能包含代码。 举个例子,__init__.py能够用来自动加载子模块:

# graphics/formats/__init__.py
from . import jpg
from . import png

像这样一个文件,用户可以仅仅通过import grahpics.formats来代替import graphics.formats.jpg以及import graphics.formats.png。

 

 

10.2 对所有符号的导入进行精准控制。

问题:

当用户使用from module import * 语句时,希望对导入的模块进行精准控制。

解决方法:

通过在模块里面定义__all__ = []来限制

def spam():
    print(123)

def grok():
    print(456)

# 通过__all__里面的名称,对模块或者包导出的符号进行限制
__all__ = ['spam']

 讨论:

如果__all__=[],那这个模块或者包将什么符号都不会导出(仅限制from module import *)的形式

如果__all__包含未定义的名称,执行import会发生AttributeError错误。

 

10.3用相对名称来导入包中的子模块。

问题:

我们将代码组织成一个包,想从其中一个子模块中导入另一个子模块,但是又不希望在import语句中硬编码包的名称。

解决方案:

 

└── mypackage
    ├── A
    │   ├── __init__.py
    │   ├── grok.py
    │   └── spam.py
    ├── B
    │   ├── __init__.py
    │   └── bar.py
    └── __init__.py

这样的目录情况下。如果模块mypackage.A.spam想导入同一个目录下的模块grok

可以在spam里面写

from . import grok (.表示同一个目录..表示上一级目录)

如果要导入mypackage.B.bar

可以在spam里面写

from ..B import bar 

 

上面这两条import语句都是相对spam.py文件的位置来进行操作,而且其中没有包含最顶层的名称。

啥叫顶层的名称,你运行的脚本的路径下所有的模块,包的文件名都属于顶层名称。

 

讨论:

在包的内部,通过相对路径与绝对路径都可以对包进行导入。

# mypackage/A/spam.py 在这个目录下导入grok

from mypackage.A import grok

from . import grok

import grok

第一,第二是正确的,第三是错误的。

像mypackage.A这样使用绝对路径名的不利之处是这将顶层包名硬编码到你的源码中。如果你想重新组织它,你的代码将更脆,很难工作。 举个例子,如果你改变了包名,你就必须检查所有文件来修正源码。

同样,硬编码的名称会使移动代码变得困难。举个例子,也许有人想安装两个不同版本的软件包,只通过名称区分它们。 如果使用相对导入,那一切都ok,然而使用绝对路径名很可能会出问题。

 

import语句中的.和..语法看起来比较有趣,把它们想象成指定目录名既可。.意味着在当前目录中查找,而..B表示在../B目录中查找。

这种语法只能用在from xx import yy这样的导入语句中。

from . import grok    # 正确

import .grok    # 错误

尽管看起来似乎可以用相对导入来访问整个文件系统,但实际上是不允许跳出定义包的那个目录。

也就是说,利用句点的组合形式进入一个不是Python包的目录会使得导入出现错误。

最后,应该要提到的是相对导入只在特定的条件下才起作用。既,模块必须位于一个合适的包中才可以。

特别是,位于脚本顶层目录的模块不能使用相对导入。此外,如果包的某个部分是直接以脚本的形式执行,这样情况下也不能使用相对导入。

% python3 mypackage/A/spam.py # Relative imports fail

另一方面,如果你使用Python的-m选项来执行先前的脚本,相对导入将会正确运行。 例如:

% python3 -m mypackage.A.spam # Relative imports work

 

10.4将模块分解成多个文件。

问题:

我们想把一个模块分解成多个文件。但是,我们不想破坏现在已经在使用这个模块的代码,而是希望可以将多个单独的文件在逻辑上统一成一个单独的模块。

解决方案:

通过模块转换成包的方式将模块分解成多个单独的文件。

# mymodule.py

class A:
    def spam(self):
        print('A.spam')

class B:
    def bar(self):
        print('B.bar')

 假设将上面的模块分解成两个文件,可以将mymoudle.py模块替换成mymoudle包.

# a.py


class A:

    def spam(self):
        print('A.spam')

 

# b.py

from .a import A

class B(A):
    def bar(self):
        print('B.bar')

 

# __init__.py

from .a import A
from .b import B

 

那就可以直接通过导入包来管理各个模块。

In [142]: import mymodule                                                                            

In [143]: a = mymodule.A()                                                                           

In [144]: a.spam()                                                                                   
A.spam

In [145]: b = mymodule.B()                                                                           

In [146]: b.bar()                                                                                    
B.bar

In [147]:  

 

讨论:

引入"惰性"导入概念。前面的示例可知,__init__.py文件一次性将所有需要的组件都导入过来。

但是对于庞大的模块,也许只希望在实际需要的时候才加载那些组件。

为了实现这个目的,可以对__init__.py进行修改(对于定义函数的方式。)

# __init__.py
def A():
    from .a import A
    return A()

def B():
    from .b import B
    return B()

 

在这个版本中,类A和类B被替换为在第一次访问时加载所需的类的函数。对于用户,这看起来不会有太大的不同。 例如:

>>> import mymodule
>>> a = mymodule.A()
>>> a.spam()
A.spam
>>>

 

延迟加载的主要缺点是继承和类型检查可能会中断。你可能会稍微改变你的代码,例如:

if isinstance(x, mymodule.A): # Error
...

if isinstance(x, mymodule.a.A): # Ok
...

 

10.5让各个目录下的代码在统一的命名空间下。

问题:

有一个庞大的代码库,其中有很多部分可能是由不同的人来维护和发布的。每个部分都组织成一个目录,就像包一样。

但是,与其把每个部分都安装为独特命名的包,我们更想把所有的部分联合在一起,用一个统一的前缀来命名。

解决方案:

我们可以定义一个顶层的Python包,把它作为命名空间来管理大量的子模块。

├── bar-package
│   └── spam
│       └── blah.py
└── foo-package
    └── spam
        └── grok.py

 spam用来作为公共的命名空间,删除里面的__init__.py文件。

现在把bar-package,foo-package添加到Python查寻路径中。

In [155]: import sys                                                                                 

In [156]: sys.path.extend(['bar-package','foo-package'])                                             

In [157]: import spam.blah                                                                           

In [158]: import spam.grok                                                                           

In [159]:  

 这个时候,这两个不同的包目录合并在了一起,我们可以随意的导入任意一个。

 

讨论:

在这里工作的机制被称为“包命名空间”的一个特征。从本质上讲,包命名空间是一种特殊的封装设计,为合并不同的目录的代码到一个共同的命名空间。对于大的框架,这可能是有用的,因为它允许一个框架的部分被单独地安装下载。它也使人们能够轻松地为这样的框架编写第三方附加组件和其他扩展。

包命名空间的关键是确保顶级目录中没有__init__.py文件来作为共同的命名空间。缺失__init__.py文件使得在导入包的时候会发生有趣的事情:这并没有产生错误,解释器创建了一个由所有包含匹配包名的目录组成的列表。特殊的包命名空间模块被创建,只读的目录列表副本被存储在其__path__变量中。 举个例子:

>>> import spam
>>> spam.__path__
_NamespacePath(['foo-package/spam', 'bar-package/spam'])
>>>

在定位包的子组件时,目录__path__将被用到(例如, 当导入spam.grok或者spam.blah的时候).

包命名空间的一个重要特点是任何人都可以用自己的代码来扩展命名空间。举个例子,假设你自己的代码目录像这样:

my-package/
    spam/
        custom.py

如果你将你的代码目录和其他包一起添加到sys.path,这将无缝地合并到别的spam包目录中:

>>> import spam.custom
>>> import spam.grok
>>> import spam.blah
>>>

一个包是否被作为一个包命名空间的主要方法是检查其__file__属性。如果没有,那包是个命名空间。这也可以由其字符表现形式中的“namespace”这个词体现出来。

>>> spam.__file__
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
AttributeError: 'module' object has no attribute '__file__'
>>> spam
<module 'spam' (namespace)>
>>>

Python3.7测试,包命名空间.__file__不会报错,就没有显示。

In [161]: spam                                                                                       
Out[161]: <module 'spam' (namespace)>

In [162]: spam.__file__                                                                              

In [163]: spam.__path__                                                                              
Out[163]: _NamespacePath(['bar-package/spam', 'foo-package/spam'])

 

10.6重新加载模块。

问题:

因为对模块的源代码做了修改,我们想重新加载一个已经加载过了的模块。

解决方案:

重新加载之前已加载过的模块,使用importlib.reload()

In [164]: import imp                                                                                 
/usr/local/bin/ipython:1: DeprecationWarning: the imp module is deprecated in favour of importlib; see the module's documentation for alternative uses
  #!/usr/local/opt/python/bin/python3.7

In [165]: import importlib                                                                           

In [166]: importlib.reload(time)                                                                     
Out[166]: <module 'time' (built-in)>

In [167]: importlib.reload(spam)                                                                     
Out[167]: <module 'spam' (namespace)>

In [168]:  

 讨论:

In [12]: import spam                                                                                 

In [13]: from spam import grok                                                                       

In [14]: spam.bar()                                                                                  
bar

In [15]: grok()                                                                                      
grok

In [16]: import spam                                                                                 

In [17]: spam.grok                                                                                   
Out[17]: <function spam.grok()>

In [18]: spam.grok()                                                                                 
grok

In [19]: import importlib                                                                            

In [20]: importlib.reload(spam)                                                                      
Out[20]: <module 'spam' from '/Users/shijianzhong/study/PythonCookbook/chapter_10/t10_6/spam.py'>

In [21]: spam.grok()                                                                                 
New_grok

In [22]: grok()                                                                                      
grok

 这个中间,我已经修改了

# spam.py

def bar():
    print('bar')

def grok():
    print('New_grok')

 所以reload()操作会擦除模块底层字典(__dict__)的内容,并通过重新执行模块的源代码来刷新它。模块对象本身的标识并不会改变(既,调用id()的结果)。

但是对于使用了from module import name 这样的导入定义,reload()是不会取更新的。

 

10.7让目录或zip文件成为可运行的脚本

问题:

我们的程序已经从一个简单的脚本进化为一个设计多个文件的应用。希望能由某中简单的方法让用户运行这个程序

解决方案:

在目录下创建一个__main__.py文件。

如果你的应用程序已经有多个文件,你可以把你的应用程序放进它自己的目录并添加一个__main__.py文件。 举个例子,你可以像这样创建目录:

myapplication/
    spam.py
    bar.py
    grok.py
    __main__.py

如果__main__.py存在,你可以简单地在顶级目录运行Python解释器:

bash % python3 myapplication

解释器将执行__main__.py文件作为主程序。

如果你将你的代码打包成zip文件,这种技术同样也适用,举个例子:

bash % ls
spam.py bar.py grok.py __main__.py
bash % zip -r myapp.zip *.py
bash % python3 myapp.zip
... output from __main__.py ...

讨论

创建一个目录或zip文件并添加__main__.py文件来将一个更大的Python应用打包是可行的。这和作为标准库被安装到Python库的代码包是有一点区别的。相反,这只是让别人执行的代码包。

由于目录和zip文件与正常文件有一点不同,你可能还需要增加一个shell脚本,使执行更加容易。例如,如果代码文件名为myapp.zip,你可以创建这样一个顶级脚本:

#!/usr/bin/env python3 /usr/local/bin/myapp.zip

 

10.8读取包中的数据文件

问题

我们的代码需要读取包中的一个数据文件,尽可能的以可移植的方式来处理。

解决方法:

使用pkgutil

假设你的包中的文件组织成如下:

mypackage/
    __init__.py
    somedata.dat
    spam.py

现在假设spam.py文件需要读取somedata.dat文件中的内容。你可以用以下代码来完成:

# spam.py
import pkgutil
data = pkgutil.get_data(__package__, 'somedata.dat')

 由此产生的变量是包含该文件的原始内容的字节字符串。

讨论:

要读取数据文件,你可能会倾向于编写使用内置的I/ O功能的代码,如open()。但是这种方法也有一些问题。

首先,一个包对解释器的当前工作目录几乎没有控制权。因此,编程时任何I/O操作都必须使用绝对文件名。由于每个模块包含有完整路径的__file__变量,这弄清楚它的路径不是不可能,但它很凌乱。

第二,包通常安装作为.zip或.egg文件,这些文件并不像在文件系统上的一个普通目录里那样被保存。因此,你试图用open()对一个包含数据文件的归档文件进行操作,它根本不会工作。

pkgutil.get_data()函数是一个读取数据文件的高级工具,不用管包是如何安装以及安装在哪。它只是工作并将文件内容以字节字符串返回给你

get_data()的第一个参数是包含包名的字符串。你可以直接使用包名,也可以使用特殊的变量,比如__package__。第二个参数是包内文件的相对名称。如果有必要,可以使用标准的Unix命名规范到不同的目录,只要最后的目录仍然位于包中。

 

10.9添加目录到sys.path中。

问题:

想将一些代码导入到sys.path中,但又不想将其硬编码到代码中。

解决方案:

第一种方法可以将新的目录添加到sys.path中去。通过PYTHONPATH环境变量来添加。

bash % env PYTHONPATH=/some/dir:/other/dir python3
Python 3.3.0 (default, Oct 4 2012, 10:17:33)
[GCC 4.2.1 (Apple Inc. build 5666) (dot 3)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import sys
>>> sys.path
['', '/some/dir', '/other/dir', ...]
>>>

 在自定义应用程序中,这样的环境变量可在程序启动时设置或通过shell脚本。

第二种方法就是创建一个.pth文件

# myapplication.pth
/some/dir
/other/dir

 这个.pth文件需要放在某个Python的site-packages目录,通常位于/usr/local/lib/python3.3/site-packages 或者 ~/.local/lib/python3.3/sitepackages。当解释器启动时,.pth文件里列举出来的存在于文件系统的目录将被添加到sys.path。安装一个.pth文件可能需要管理员权限,如果它被添加到系统级的Python解释器。

 

讨论:

比起费力地找文件,你可能会倾向于写一个代码手动调节sys.path的值。例如:

import sys
sys.path.insert(0, '/some/dir')
sys.path.insert(0, '/other/dir')

 

虽然这能“工作”,但是在实践中极为脆弱,应尽量避免使用。这种方法的问题是,它将目录名硬编码到了你的源代码。如果你的代码被移到一个新的位置,这会导致维护问题。更好的做法是在不修改源代码的情况下,将path配置到其他地方。如果您使用模块级的变量来精心构造一个适当的绝对路径,有时你可以解决硬编码目录的问题,比如__file__。举个例子:

import sys
from os.path import abspath, join, dirname
# 通过地址的拼接dirname获得根路径,abspath或者绝对路径,join合并路径 sys.path.insert(0, join(abspath(dirname(__file__)), 'src'))

这将src目录添加到path里,和执行插入步骤的代码在同一个目录里。

site-packages目录是第三方包和模块安装的目录。如果你手动安装你的代码,它将被安装到site-packages目录。虽然用于配置path的.pth文件必须放置在site-packages里,但它配置的路径可以是系统上任何你希望的目录。因此,你可以把你的代码放在一系列不同的目录,只要那些目录包含在.pth文件里。

 

10.10使用字符串中给定的名称来导入模块

问题:

你想导入一个模块,但是模块的名字在字符串里。你想对字符串调用导入命令。

解决方法:

importlib.import_module()

In [64]: t = importlib.import_module('time')                                                         

In [65]: t.time()                                                                                    
Out[65]: 1584450629.130104

In [66]:  

improt_module基本和import完成的步骤相同,但是在import_module会把模块对象作为结果返回给你。

我们只需要将它保存在一个变量里,之后把它当做普通的模块使用。

import——module()也可以实现相对导入,但是需要一个额外的参数。

import importlib
# Same as 'from . import b'
# 第一个参数是相对路径以及导入的模块,或者包,第二个是定位的包位置。用__package__代替了
b = importlib.import_module('.b',__package__)

 讨论:

通过__import__()函数也可以实现导入,但importlib.import_module通常更容易使用一些。

 

10.11利用import钩子从远端极其上加载模块。

问题:

你想自定义Python的import语句,使得它能从远程机器上面透明的加载模块。

解决方案

首先要提出来的是安全问题。本节讨论的思想如果没有一些额外的安全和认知机制的话会很糟糕。 也就是说,我们的主要目的是深入分析Python的import语句机制。 如果你理解了本节内部原理,你就能够为其他任何目的而自定义import。 有了这些,让我们继续向前走。

本节核心是设计导入语句的扩展功能。有很多种方法可以做这个, 不过为了演示的方便,我们开始先构造下面这个Python代码结构:

testcode/
    spam.py
    fib.py
    grok/
        __init__.py
        blah.py

这些文件的内容并不重要,不过我们在每个文件中放入了少量的简单语句和函数, 这样你可以测试它们并查看当它们被导入时的输出。例如:

# spam.py
print("I'm spam")

def hello(name):
    print('Hello %s' % name)

# fib.py
print("I'm fib")

def fib(n):
    if n < 2:
        return 1
    else:
        return fib(n-1) + fib(n-2)

# grok/__init__.py
print("I'm grok.__init__")

# grok/blah.py
print("I'm grok.blah")

这里的目的是允许这些文件作为模块被远程访问。 也许最简单的方式就是将它们发布到一个web服务器上面。在testcode目录中像下面这样运行Python:

bash % cd testcode
bash % python3 -m http.server 15000
Serving HTTP on 0.0.0.0 port 15000 ...

服务器运行起来后再启动一个单独的Python解释器。 确保你可以使用 urllib 访问到远程文件。例如:

>>> from urllib.request import urlopen
>>> u = urlopen('http://localhost:15000/fib.py')
>>> data = u.read().decode('utf-8')
>>> print(data)
# fib.py
print("I'm fib")

def fib(n):
    if n < 2:
        return 1
    else:
        return fib(n-1) + fib(n-2)
>>>

从这个服务器加载源代码是接下来本节的基础。 为了替代手动的通过 urlopen() 来收集源文件, 我们通过自定义import语句来在后台自动帮我们做到。

加载远程模块的第一种方法是创建一个显式的加载函数来完成它。例如:

import imp
import urllib.request
import sys

def load_module(url):
    u = urllib.request.urlopen(url)
    source = u.read().decode('utf-8')
    mod = sys.modules.setdefault(url, imp.new_module(url))
    code = compile(source, url, 'exec')
    mod.__file__ = url
    mod.__package__ = ''
    exec(code, mod.__dict__)
    return mod

这个函数会下载源代码,并使用 compile() 将其编译到一个代码对象中, 然后在一个新创建的模块对象的字典中来执行它。下面是使用这个函数的方式:

>>> fib = load_module('http://localhost:15000/fib.py')
I'm fib
>>> fib.fib(10)
89
>>> spam = load_module('http://localhost:15000/spam.py')
I'm spam
>>> spam.hello('Guido')
Hello Guido
>>> fib
<module 'http://localhost:15000/fib.py' from 'http://localhost:15000/fib.py'>
>>> spam
<module 'http://localhost:15000/spam.py' from 'http://localhost:15000/spam.py'>
>>>

正如你所见,对于简单的模块这个是行得通的。 不过它并没有嵌入到通常的import语句中,如果要支持更高级的结构比如包就需要更多的工作了。

一个更酷的做法是创建一个自定义导入器。第一种方法是创建一个元路径导入器。如下:

# urlimport.py
import sys
import importlib.abc
import imp
from urllib.request import urlopen
from urllib.error import HTTPError, URLError
from html.parser import HTMLParser

# Debugging
import logging
log = logging.getLogger(__name__)

# Get links from a given URL
def _get_links(url):
    class LinkParser(HTMLParser):
        def handle_starttag(self, tag, attrs):
            if tag == 'a':
                attrs = dict(attrs)
                links.add(attrs.get('href').rstrip('/'))
    links = set()
    try:
        log.debug('Getting links from %s' % url)
        u = urlopen(url)
        parser = LinkParser()
        parser.feed(u.read().decode('utf-8'))
    except Exception as e:
        log.debug('Could not get links. %s', e)
    log.debug('links: %r', links)
    return links

class UrlMetaFinder(importlib.abc.MetaPathFinder):
    def __init__(self, baseurl):
        self._baseurl = baseurl
        self._links = { }
        self._loaders = { baseurl : UrlModuleLoader(baseurl) }

    def find_module(self, fullname, path=None):
        log.debug('find_module: fullname=%r, path=%r', fullname, path)
        if path is None:
            baseurl = self._baseurl
        else:
            if not path[0].startswith(self._baseurl):
                return None
            baseurl = path[0]
        parts = fullname.split('.')
        basename = parts[-1]
        log.debug('find_module: baseurl=%r, basename=%r', baseurl, basename)

        # Check link cache
        if basename not in self._links:
            self._links[baseurl] = _get_links(baseurl)

        # Check if it's a package
        if basename in self._links[baseurl]:
            log.debug('find_module: trying package %r', fullname)
            fullurl = self._baseurl + '/' + basename
            # Attempt to load the package (which accesses __init__.py)
            loader = UrlPackageLoader(fullurl)
            try:
                loader.load_module(fullname)
                self._links[fullurl] = _get_links(fullurl)
                self._loaders[fullurl] = UrlModuleLoader(fullurl)
                log.debug('find_module: package %r loaded', fullname)
            except ImportError as e:
                log.debug('find_module: package failed. %s', e)
                loader = None
            return loader
        # A normal module
        filename = basename + '.py'
        if filename in self._links[baseurl]:
            log.debug('find_module: module %r found', fullname)
            return self._loaders[baseurl]
        else:
            log.debug('find_module: module %r not found', fullname)
            return None

    def invalidate_caches(self):
        log.debug('invalidating link cache')
        self._links.clear()

# Module Loader for a URL
class UrlModuleLoader(importlib.abc.SourceLoader):
    def __init__(self, baseurl):
        self._baseurl = baseurl
        self._source_cache = {}

    def module_repr(self, module):
        return '<urlmodule %r from %r>' % (module.__name__, module.__file__)

    # Required method
    def load_module(self, fullname):
        code = self.get_code(fullname)
        mod = sys.modules.setdefault(fullname, imp.new_module(fullname))
        mod.__file__ = self.get_filename(fullname)
        mod.__loader__ = self
        mod.__package__ = fullname.rpartition('.')[0]
        exec(code, mod.__dict__)
        return mod

    # Optional extensions
    def get_code(self, fullname):
        src = self.get_source(fullname)
        return compile(src, self.get_filename(fullname), 'exec')

    def get_data(self, path):
        pass

    def get_filename(self, fullname):
        return self._baseurl + '/' + fullname.split('.')[-1] + '.py'

    def get_source(self, fullname):
        filename = self.get_filename(fullname)
        log.debug('loader: reading %r', filename)
        if filename in self._source_cache:
            log.debug('loader: cached %r', filename)
            return self._source_cache[filename]
        try:
            u = urlopen(filename)
            source = u.read().decode('utf-8')
            log.debug('loader: %r loaded', filename)
            self._source_cache[filename] = source
            return source
        except (HTTPError, URLError) as e:
            log.debug('loader: %r failed. %s', filename, e)
            raise ImportError("Can't load %s" % filename)

    def is_package(self, fullname):
        return False

# Package loader for a URL
class UrlPackageLoader(UrlModuleLoader):
    def load_module(self, fullname):
        mod = super().load_module(fullname)
        mod.__path__ = [ self._baseurl ]
        mod.__package__ = fullname

    def get_filename(self, fullname):
        return self._baseurl + '/' + '__init__.py'

    def is_package(self, fullname):
        return True

# Utility functions for installing/uninstalling the loader
_installed_meta_cache = { }
def install_meta(address):
    if address not in _installed_meta_cache:
        finder = UrlMetaFinder(address)
        _installed_meta_cache[address] = finder
        sys.meta_path.append(finder)
        log.debug('%r installed on sys.meta_path', finder)

def remove_meta(address):
    if address in _installed_meta_cache:
        finder = _installed_meta_cache.pop(address)
        sys.meta_path.remove(finder)
        log.debug('%r removed from sys.meta_path', finder)

下面是一个交互会话,演示了如何使用前面的代码:

>>> # importing currently fails
>>> import fib
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ImportError: No module named 'fib'
>>> # Load the importer and retry (it works)
>>> import urlimport
>>> urlimport.install_meta('http://localhost:15000')
>>> import fib
I'm fib
>>> import spam
I'm spam
>>> import grok.blah
I'm grok.__init__
I'm grok.blah
>>> grok.blah.__file__
'http://localhost:15000/grok/blah.py'
>>>

这个特殊的方案会安装一个特别的查找器 UrlMetaFinder 实例, 作为 sys.meta_path 中最后的实体。 当模块被导入时,会依据 sys.meta_path 中的查找器定位模块。 在这个例子中,UrlMetaFinder 实例是最后一个查找器方案, 当模块在任何一个普通地方都找不到的时候就触发它。

作为常见的实现方案,UrlMetaFinder 类包装在一个用户指定的URL上。 在内部,查找器通过抓取指定URL的内容构建合法的链接集合。 导入的时候,模块名会跟已有的链接作对比。如果找到了一个匹配的, 一个单独的 UrlModuleLoader 类被用来从远程机器上加载源代码并创建最终的模块对象。 这里缓存链接的一个原因是避免不必要的HTTP请求重复导入。

自定义导入的第二种方法是编写一个钩子直接嵌入到 sys.path 变量中去, 识别某些目录命名模式。 在 urlimport.py 中添加如下的类和支持函数:

# urlimport.py
# ... include previous code above ...
# Path finder class for a URL
class UrlPathFinder(importlib.abc.PathEntryFinder):
    def __init__(self, baseurl):
        self._links = None
        self._loader = UrlModuleLoader(baseurl)
        self._baseurl = baseurl

    def find_loader(self, fullname):
        log.debug('find_loader: %r', fullname)
        parts = fullname.split('.')
        basename = parts[-1]
        # Check link cache
        if self._links is None:
            self._links = [] # See discussion
            self._links = _get_links(self._baseurl)

        # Check if it's a package
        if basename in self._links:
            log.debug('find_loader: trying package %r', fullname)
            fullurl = self._baseurl + '/' + basename
            # Attempt to load the package (which accesses __init__.py)
            loader = UrlPackageLoader(fullurl)
            try:
                loader.load_module(fullname)
                log.debug('find_loader: package %r loaded', fullname)
            except ImportError as e:
                log.debug('find_loader: %r is a namespace package', fullname)
                loader = None
            return (loader, [fullurl])

        # A normal module
        filename = basename + '.py'
        if filename in self._links:
            log.debug('find_loader: module %r found', fullname)
            return (self._loader, [])
        else:
            log.debug('find_loader: module %r not found', fullname)
            return (None, [])

    def invalidate_caches(self):
        log.debug('invalidating link cache')
        self._links = None

# Check path to see if it looks like a URL
_url_path_cache = {}
def handle_url(path):
    if path.startswith(('http://', 'https://')):
        log.debug('Handle path? %s. [Yes]', path)
        if path in _url_path_cache:
            finder = _url_path_cache[path]
        else:
            finder = UrlPathFinder(path)
            _url_path_cache[path] = finder
        return finder
    else:
        log.debug('Handle path? %s. [No]', path)

def install_path_hook():
    sys.path_hooks.append(handle_url)
    sys.path_importer_cache.clear()
    log.debug('Installing handle_url')

def remove_path_hook():
    sys.path_hooks.remove(handle_url)
    sys.path_importer_cache.clear()
    log.debug('Removing handle_url')

要使用这个路径查找器,你只需要在 sys.path 中加入URL链接。例如:

>>> # Initial import fails
>>> import fib
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
ImportError: No module named 'fib'

>>> # Install the path hook
>>> import urlimport
>>> urlimport.install_path_hook()

>>> # Imports still fail (not on path)
>>> import fib
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
ImportError: No module named 'fib'

>>> # Add an entry to sys.path and watch it work
>>> import sys
>>> sys.path.append('http://localhost:15000')
>>> import fib
I'm fib
>>> import grok.blah
I'm grok.__init__
I'm grok.blah
>>> grok.blah.__file__
'http://localhost:15000/grok/blah.py'
>>>

关键点就是 handle_url() 函数,它被添加到了 sys.path_hooks 变量中。 当 sys.path 的实体被处理时,会调用 sys.path_hooks 中的函数。 如果任何一个函数返回了一个查找器对象,那么这个对象就被用来为 sys.path 实体加载模块。

远程模块加载跟其他的加载使用方法几乎是一样的。例如:

>>> fib
<urlmodule 'fib' from 'http://localhost:15000/fib.py'>
>>> fib.__name__
'fib'
>>> fib.__file__
'http://localhost:15000/fib.py'
>>> import inspect
>>> print(inspect.getsource(fib))
# fib.py
print("I'm fib")

def fib(n):
    if n < 2:
        return 1
    else:
        return fib(n-1) + fib(n-2)
>>>

讨论

在详细讨论之前,有点要强调的是,Python的模块、包和导入机制是整个语言中最复杂的部分, 即使经验丰富的Python程序员也很少能精通它们。 我在这里推荐一些值的去读的文档和书籍,包括 importlib module 和 PEP 302. 文档内容在这里不会被重复提到,不过我在这里会讨论一些最重要的部分。

首先,如果你想创建一个新的模块对象,使用 imp.new_module() 函数:

>>> import imp
>>> m = imp.new_module('spam')
>>> m
<module 'spam'>
>>> m.__name__
'spam'
>>>

模块对象通常有一些期望属性,包括 __file__ (运行模块加载语句的文件名) 和 __package__ (包名)。

其次,模块会被解释器缓存起来。模块缓存可以在字典 sys.modules 中被找到。 因为有了这个缓存机制,通常可以将缓存和模块的创建通过一个步骤完成:

>>> import sys
>>> import imp
>>> m = sys.modules.setdefault('spam', imp.new_module('spam'))
>>> m
<module 'spam'>
>>>

如果给定模块已经存在那么就会直接获得已经被创建过的模块,例如:

>>> import math
>>> m = sys.modules.setdefault('math', imp.new_module('math'))
>>> m
<module 'math' from '/usr/local/lib/python3.3/lib-dynload/math.so'>
>>> m.sin(2)
0.9092974268256817
>>> m.cos(2)
-0.4161468365471424
>>>

由于创建模块很简单,很容易编写简单函数比如第一部分的 load_module() 函数。 这个方案的一个缺点是很难处理复杂情况比如包的导入。 为了处理一个包,你要重新实现普通import语句的底层逻辑(比如检查目录,查找__init__.py文件, 执行那些文件,设置路径等)。这个复杂性就是为什么最好直接扩展import语句而不是自定义函数的一个原因。

扩展import语句很简单,但是会有很多移动操作。 最高层上,导入操作被一个位于sys.meta_path列表中的“元路径”查找器处理。 如果你输出它的值,会看到下面这样:

>>> from pprint import pprint
>>> pprint(sys.meta_path)
[<class '_frozen_importlib.BuiltinImporter'>,
<class '_frozen_importlib.FrozenImporter'>,
<class '_frozen_importlib.PathFinder'>]
>>>

当执行一个语句比如 import fib 时,解释器会遍历sys.mata_path中的查找器对象, 调用它们的 find_module() 方法定位正确的模块加载器。 可以通过实验来看看:

>>> class Finder:
...     def find_module(self, fullname, path):
...         print('Looking for', fullname, path)
...         return None
...
>>> import sys
>>> sys.meta_path.insert(0, Finder()) # Insert as first entry
>>> import math
Looking for math None
>>> import types
Looking for types None
>>> import threading
Looking for threading None
Looking for time None
Looking for traceback None
Looking for linecache None
Looking for tokenize None
Looking for token None
>>>

注意看 find_module() 方法是怎样在每一个导入就被触发的。 这个方法中的path参数的作用是处理包。 多个包被导入,就是一个可在包的 __path__ 属性中找到的路径列表。 要找到包的子组件就要检查这些路径。 比如注意对于 xml.etree 和 xml.etree.ElementTree 的路径配置:

>>> import xml.etree.ElementTree
Looking for xml None
Looking for xml.etree ['/usr/local/lib/python3.3/xml']
Looking for xml.etree.ElementTree ['/usr/local/lib/python3.3/xml/etree']
Looking for warnings None
Looking for contextlib None
Looking for xml.etree.ElementPath ['/usr/local/lib/python3.3/xml/etree']
Looking for _elementtree None
Looking for copy None
Looking for org None
Looking for pyexpat None
Looking for ElementC14N None
>>>

在 sys.meta_path 上查找器的位置很重要,将它从队头移到队尾,然后再试试导入看:

>>> del sys.meta_path[0]
>>> sys.meta_path.append(Finder())
>>> import urllib.request
>>> import datetime

现在你看不到任何输出了,因为导入被sys.meta_path中的其他实体处理。 这时候,你只有在导入不存在模块的时候才能看到它被触发:

>>> import fib
Looking for fib None
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
ImportError: No module named 'fib'
>>> import xml.superfast
Looking for xml.superfast ['/usr/local/lib/python3.3/xml']
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
ImportError: No module named 'xml.superfast'
>>>

你之前安装过一个捕获未知模块的查找器,这个是 UrlMetaFinder 类的关键。 一个 UrlMetaFinder 实例被添加到 sys.meta_path 的末尾,作为最后一个查找器方案。 如果被请求的模块名不能定位,就会被这个查找器处理掉。 处理包的时候需要注意,在path参数中指定的值需要被检查,看它是否以查找器中注册的URL开头。 如果不是,该子模块必须归属于其他查找器并被忽略掉。

对于包的其他处理可在 UrlPackageLoader 类中被找到。 这个类不会导入包名,而是去加载对应的 __init__.py 文件。 它也会设置模块的 __path__ 属性,这一步很重要, 因为在加载包的子模块时这个值会被传给后面的 find_module() 调用。 基于路径的导入钩子是这些思想的一个扩展,但是采用了另外的方法。 我们都知道,sys.path 是一个Python查找模块的目录列表,例如:

>>> from pprint import pprint
>>> import sys
>>> pprint(sys.path)
['',
'/usr/local/lib/python33.zip',
'/usr/local/lib/python3.3',
'/usr/local/lib/python3.3/plat-darwin',
'/usr/local/lib/python3.3/lib-dynload',
'/usr/local/lib/...3.3/site-packages']
>>>

在 sys.path 中的每一个实体都会被额外的绑定到一个查找器对象上。 你可以通过查看 sys.path_importer_cache 去看下这些查找器:

>>> pprint(sys.path_importer_cache)
{'.': FileFinder('.'),
'/usr/local/lib/python3.3': FileFinder('/usr/local/lib/python3.3'),
'/usr/local/lib/python3.3/': FileFinder('/usr/local/lib/python3.3/'),
'/usr/local/lib/python3.3/collections': FileFinder('...python3.3/collections'),
'/usr/local/lib/python3.3/encodings': FileFinder('...python3.3/encodings'),
'/usr/local/lib/python3.3/lib-dynload': FileFinder('...python3.3/lib-dynload'),
'/usr/local/lib/python3.3/plat-darwin': FileFinder('...python3.3/plat-darwin'),
'/usr/local/lib/python3.3/site-packages': FileFinder('...python3.3/site-packages'),
'/usr/local/lib/python33.zip': None}
>>>

sys.path_importer_cache 比 sys.path 会更大点, 因为它会为所有被加载代码的目录记录它们的查找器。 这包括包的子目录,这些通常在 sys.path 中是不存在的。

要执行 import fib ,会顺序检查 sys.path 中的目录。 对于每个目录,名称“fib”会被传给相应的 sys.path_importer_cache 中的查找器。 这个可以让你创建自己的查找器并在缓存中放入一个实体。试试这个:

>>> class Finder:
... def find_loader(self, name):
...     print('Looking for', name)
...     return (None, [])
...
>>> import sys
>>> # Add a "debug" entry to the importer cache
>>> sys.path_importer_cache['debug'] = Finder()
>>> # Add a "debug" directory to sys.path
>>> sys.path.insert(0, 'debug')
>>> import threading
Looking for threading
Looking for time
Looking for traceback
Looking for linecache
Looking for tokenize
Looking for token
>>>

在这里,你可以为名字“debug”创建一个新的缓存实体并将它设置成 sys.path 上的第一个。 在所有接下来的导入中,你会看到你的查找器被触发了。 不过,由于它返回 (None, []),那么处理进程会继续处理下一个实体。

sys.path_importer_cache 的使用被一个存储在 sys.path_hooks 中的函数列表控制。 试试下面的例子,它会清除缓存并给 sys.path_hooks 添加一个新的路径检查函数

>>> sys.path_importer_cache.clear()
>>> def check_path(path):
...     print('Checking', path)
...     raise ImportError()
...
>>> sys.path_hooks.insert(0, check_path)
>>> import fib
Checked debug
Checking .
Checking /usr/local/lib/python33.zip
Checking /usr/local/lib/python3.3
Checking /usr/local/lib/python3.3/plat-darwin
Checking /usr/local/lib/python3.3/lib-dynload
Checking /Users/beazley/.local/lib/python3.3/site-packages
Checking /usr/local/lib/python3.3/site-packages
Looking for fib
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
ImportError: No module named 'fib'
>>>

正如你所见,check_path() 函数被每个 sys.path 中的实体调用。 不顾,由于抛出了 ImportError 异常, 啥都不会发生了(仅仅将检查转移到sys.path_hooks的下一个函数)。

知道了怎样sys.path是怎样被处理的,你就能构建一个自定义路径检查函数来查找文件名,不然URL。例如:

>>> def check_url(path):
...     if path.startswith('http://'):
...         return Finder()
...     else:
...         raise ImportError()
...
>>> sys.path.append('http://localhost:15000')
>>> sys.path_hooks[0] = check_url
>>> import fib
Looking for fib # Finder output!
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
ImportError: No module named 'fib'

>>> # Notice installation of Finder in sys.path_importer_cache
>>> sys.path_importer_cache['http://localhost:15000']
<__main__.Finder object at 0x10064c850>
>>>

这就是本节最后部分的关键点。事实上,一个用来在sys.path中查找URL的自定义路径检查函数已经构建完毕。 当它们被碰到的时候,一个新的 UrlPathFinder 实例被创建并被放入 sys.path_importer_cache. 之后,所有需要检查 sys.path 的导入语句都会使用你的自定义查找器。

基于路径导入的包处理稍微有点复杂,并且跟 find_loader() 方法返回值有关。 对于简单模块,find_loader() 返回一个元组(loader, None), 其中的loader是一个用于导入模块的加载器实例。

对于一个普通的包,find_loader() 返回一个元组(loader, path), 其中的loader是一个用于导入包(并执行__init__.py)的加载器实例, path是一个会初始化包的 __path__ 属性的目录列表。 例如,如果基础URL是 http://localhost:15000 并且一个用户执行 import grok , 那么 find_loader() 返回的path就会是 [ ‘http://localhost:15000/grok’ ]

find_loader() 还要能处理一个命名空间包。 一个命名空间包中有一个合法的包目录名,但是不存在__init__.py文件。 这样的话,find_loader() 必须返回一个元组(None, path), path是一个目录列表,由它来构建包的定义有__init__.py文件的__path__属性。 对于这种情况,导入机制会继续前行去检查sys.path中的目录。 如果找到了命名空间包,所有的结果路径被加到一起来构建最终的命名空间包。 关于命名空间包的更多信息请参考10.5小节。

所有的包都包含了一个内部路径设置,可以在__path__属性中看到,例如:

>>> import xml.etree.ElementTree
>>> xml.__path__
['/usr/local/lib/python3.3/xml']
>>> xml.etree.__path__
['/usr/local/lib/python3.3/xml/etree']
>>>

之前提到,__path__的设置是通过 find_loader() 方法返回值控制的。 不过,__path__接下来也被sys.path_hooks中的函数处理。 因此,但包的子组件被加载后,位于__path__中的实体会被 handle_url() 函数检查。 这会导致新的 UrlPathFinder 实例被创建并且被加入到 sys.path_importer_cache 中。

还有个难点就是 handle_url() 函数以及它跟内部使用的 _get_links() 函数之间的交互。 如果你的查找器实现需要使用到其他模块(比如urllib.request), 有可能这些模块会在查找器操作期间进行更多的导入。 它可以导致 handle_url() 和其他查找器部分陷入一种递归循环状态。 为了解释这种可能性,实现中有一个被创建的查找器缓存(每一个URL一个)。 它可以避免创建重复查找器的问题。 另外,下面的代码片段可以确保查找器不会在初始化链接集合的时候响应任何导入请求:

# Check link cache
if self._links is None:
    self._links = [] # See discussion
    self._links = _get_links(self._baseurl)

最后,查找器的 invalidate_caches() 方法是一个工具方法,用来清理内部缓存。 这个方法再用户调用 importlib.invalidate_caches() 的时候被触发。 如果你想让URL导入者重新读取链接列表的话可以使用它。

对比下两种方案(修改sys.meta_path或使用一个路径钩子)。 使用sys.meta_path的导入者可以按照自己的需要自由处理模块。 例如,它们可以从数据库中导入或以不同于一般模块/包处理方式导入。 这种自由同样意味着导入者需要自己进行内部的一些管理。 另外,基于路径的钩子只是适用于对sys.path的处理。 通过这种扩展加载的模块跟普通方式加载的特性是一样的。

如果到现在为止你还是不是很明白,那么可以通过增加一些日志打印来测试下本节。像下面这样:

>>> import logging
>>> logging.basicConfig(level=logging.DEBUG)
>>> import urlimport
>>> urlimport.install_path_hook()
DEBUG:urlimport:Installing handle_url
>>> import fib
DEBUG:urlimport:Handle path? /usr/local/lib/python33.zip. [No]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ImportError: No module named 'fib'
>>> import sys
>>> sys.path.append('http://localhost:15000')
>>> import fib
DEBUG:urlimport:Handle path? http://localhost:15000. [Yes]
DEBUG:urlimport:Getting links from http://localhost:15000
DEBUG:urlimport:links: {'spam.py', 'fib.py', 'grok'}
DEBUG:urlimport:find_loader: 'fib'
DEBUG:urlimport:find_loader: module 'fib' found
DEBUG:urlimport:loader: reading 'http://localhost:15000/fib.py'
DEBUG:urlimport:loader: 'http://localhost:15000/fib.py' loaded
I'm fib
>>>

 这一节太难了,很多模块都没使用过。跳过了。

 

10.12 在模块加载时为其打补丁。

 

 

 

 

 

 

 

推荐阅读