关于python:py2app在构建期间拾取包的.git子目录

py2app picking up .git subdir of a package during build

我们在工厂广泛使用py2app来生成独立的.app包,以便于内部部署,而不存在依赖关系问题。我最近注意到了一件事,不知道它是如何开始的,那就是当构建一个.app时,py2app开始包含我们主库的.git目录。

例如,commonlib是我们的根python库包,它是一个git repo。在这个包下面是各种子包,如数据库、实用程序等。

1
2
3
4
5
6
7
8
commonLib/
    |- .git/ # because commonLib is a git repo
    |- __init__.py
    |- database/
        |- __init__.py
    |- utility/
        |- __init__.py
    # ... etc

在一个给定的项目中,比如foo,我们将像from commonLib import xyz这样进行进口,以使用我们的公共包。通过PY2APP的建筑看起来像:python setup.py py2app

所以我最近看到的一个问题是,在为项目foo构建应用程序时,我会看到它将commonlib/.git/中的所有内容都包含到应用程序中,这是一个额外的膨胀。py2app有一个excludes选项,但这似乎只适用于python模块。我不太明白排除.git子目录需要什么,或者实际上,是什么导致它首先被包括在其中。

有人在使用Git repo的python包导入时遇到过这种情况吗?在每个项目的setup.py文件中没有任何变化,commonlib始终是一个git repo。所以我唯一能想到的是PY2APP及其DEP的版本,它们显然是随着时间的推移而升级的。

编辑

我现在使用的是最新的PY2AP0.6.4。另外,我的setup.py是前一段时间从py2applet生成的,但从那时起就被手工配置,并作为每个新项目的模板复制过来。我对这些项目中的每一个都使用了pyqt4/sip,所以这也让我怀疑这是否是其中一个配方的问题?

更新

从第一个答案开始,我尝试使用exclude_package_data设置的各种组合来解决这个问题。似乎没有什么可以强制排除.git目录。以下是我的setup.py文件的一般外观示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from setuptools import setup
from myApp import VERSION

appname = 'MyApp'
APP = ['myApp.py']
DATA_FILES = []
OPTIONS = {
    'includes': 'atexit, sip, PyQt4.QtCore, PyQt4.QtGui',
    'strip': True,
    'iconfile':'ui/myApp.icns',
    'resources':['src/myApp.png'],
    'plist':{
        'CFBundleIconFile':'ui/myApp.icns',
        'CFBundleIdentifier':'com.company.myApp',
        'CFBundleGetInfoString': appname,
        'CFBundleVersion' : VERSION,
        'CFBundleShortVersionString' : VERSION
        }
    }

setup(
    app=APP,
    data_files=DATA_FILES,
    options={'py2app': OPTIONS},
    setup_requires=['py2app'],
)

我尝试过以下方法:

1
2
3
4
5
6
7
8
setup(
    ...
    exclude_package_data = { 'commonLib': ['.git'] },
    #exclude_package_data = { '': ['.git'] },
    #exclude_package_data = { 'commonLib/.git/': ['*'] },
    #exclude_package_data = { '.git': ['*'] },
    ...
)

更新2

我发布了自己的答案,在distutils上做monkeypatch。它很难看,也不受欢迎,但在有人能给我一个更好的解决方案之前,我想这就是我所拥有的。


我正在为自己的问题添加一个答案,以记录到目前为止我唯一找到的工作。我的方法是使用monkeypatch distuils在创建目录或复制文件时忽略某些模式。这真的不是我想做的,但正如我所说,这是目前唯一可行的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
## setup.py ##

import re

# file_util has to come first because dir_util uses it
from distutils import file_util, dir_util

def wrapper(fn):
    def wrapped(src, *args, **kwargs):
        if not re.search(r'/\.git/?', src):
            fn(src, *args, **kwargs)
    return wrapped      

file_util.copy_file = wrapper(file_util.copy_file)
dir_util.mkpath = wrapper(dir_util.mkpath)

# now import setuptools so it uses the monkeypatched methods
from setuptools import setup

希望有人会对此发表评论,并告诉我一个更高级别的方法来避免这样做。但到目前为止,我可能会把它包装成一个实用方法,比如exclude_data_patterns(re_pattern),以便在我的项目中重用。


有一个很好的答案,但是我有一个更详细的答案来用白名单的方法解决这里提到的问题。为了让monkey补丁也适用于site-packages.zip之外的包,我还必须monkey补丁copy_tree(因为它在函数内部导入copy_file),这有助于生成一个独立的应用程序。

此外,我还创建了一个白名单配方来标记某些包zip不安全。这种方法使得添加白名单以外的过滤器变得容易。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
import pkgutil
from os.path import join, dirname, realpath
from distutils import log

# file_util has to come first because dir_util uses it
from distutils import file_util, dir_util
# noinspection PyUnresolvedReferences
from py2app import util


def keep_only_filter(base_mod, sub_mods):
    prefix = join(realpath(dirname(base_mod.filename)), '')
    all_prefix = [join(prefix, sm) for sm in sub_mods]
    log.info("Set filter for prefix %s" % prefix)

    def wrapped(mod):
        name = getattr(mod, 'filename', None)
        if name is None:
            # ignore anything that does not have file name
            return True
        name = join(realpath(dirname(name)), '')
        if not name.startswith(prefix):
            # ignore those that are not in this prefix
            return True
        for p in all_prefix:
            if name.startswith(p):
                return True
        # log.info('ignoring %s' % name)
        return False
    return wrapped

# define all the filters we need
all_filts = {
    'mypackage': (keep_only_filter, [
        'subpackage1', 'subpackage2',
    ]),
}


def keep_only_wrapper(fn, is_dir=False):
    filts = [(f, k[1]) for (f, k) in all_filts.iteritems()
             if k[0] == keep_only_filter]
    prefixes = {}
    for f, sms in filts:
        pkg = pkgutil.get_loader(f)
        assert pkg, '{f} package not found'.format(f=f)
        p = join(pkg.filename, '')
        sp = [join(p, sm, '') for sm in sms]
        prefixes[p] = sp

    def wrapped(src, *args, **kwargs):
        name = src
        if not is_dir:
            name = dirname(src)
        name = join(realpath(name), '')
        keep = True
        for prefix, sub_prefixes in prefixes.iteritems():
            if name == prefix:
                # let the root pass
                continue
            # if it is a package we have a filter for
            if name.startswith(prefix):
                keep = False
                for sub_prefix in sub_prefixes:
                    if name.startswith(sub_prefix):
                        keep = True
                        break
        if keep:
            return fn(src, *args, **kwargs)
        return []

    return wrapped

file_util.copy_file = keep_only_wrapper(file_util.copy_file)
dir_util.mkpath = keep_only_wrapper(dir_util.mkpath, is_dir=True)
util.copy_tree = keep_only_wrapper(util.copy_tree, is_dir=True)


class ZipUnsafe(object):
    def __init__(self, _module, _filt):
        self.module = _module
        self.filt = _filt

    def check(self, dist, mf):
        m = mf.findNode(self.module)
        if m is None:
            return None

        # Do not put this package in site-packages.zip
        if self.filt:
            return dict(
                packages=[self.module],
                filters=[self.filt[0](m, self.filt[1])],
            )
        return dict(
            packages=[self.module]
        )

# Any package that is zip-unsafe (uses __file__ ,... ) should be added here
# noinspection PyUnresolvedReferences
import py2app.recipes
for module in [
        'sklearn', 'mypackage',
]:
    filt = all_filts.get(module)
    setattr(py2app.recipes, module, ZipUnsafe(module, filt))


我在PyInstaller方面也有类似的经验,所以我不确定它是否直接适用。

在运行导出过程之前,pyinstaller创建要包含在分发中的所有文件的"清单"。根据马克的第二个建议,你可以"按摩"这个清单,以排除你想要的任何文件。包括.git或.git中的任何内容。

最后,在生成二进制文件之前,我一直在检查代码,因为不仅仅是.git膨胀(比如UML文档和qt的原始资源文件)。签出保证了一个干净的结果,并且在为二进制文件创建安装程序的过程中,我没有遇到使该过程自动化的问题。


我可以看到两个排除.git目录的选项。

  • 从代码的"干净"签出中生成应用程序。在部署新版本时,我们总是基于一个标签从一个新的svn export构建,以确保我们不会获取虚假的更改/文件。您可以在这里尝试等价物-尽管Git等价物似乎更复杂一些。

  • 修改setup.py文件以按摩应用程序中包含的文件。这可以使用文档中描述的exclude_package_data功能来完成,或者构建data_files的列表并将其传递给setup

  • 至于它突然开始发生的原因,了解您正在使用的py2app的版本可能会有所帮助,了解setup.py的内容,也许了解如何制作(手工或使用py2applet)。