打包与分发

Setuptools

目录结构

setup.py 同级的包目录都会被默认的 find_packages()检索,所以最后安装的包名是 setup.py 的同级包名。

1
2
3
4
5
6
7
8
9
$ tree
.
├── pkg1/ # pkg
├── pkg2 # pkg
│ ├── __init__.py
| ├── pkg3/ # pkg
│ └── moduleX.py
├── setup.py
└── test/ # not a pkg, discussed later

那么 pip install 执行后,使用 pip list 查到的包为 pkg1 和 pkg2. 子包 pkg3 不会被找到。为规范起见,可在 setup.py 同级目录只设顶层包。

开发

以下问题在 pip install 时不存在。顶层包可以找得到,直接导。

快速迭代时,为避免频繁 pip install,可使用 setup.py 的 develop 选项 ( 见快速迭代一章 ),此时只能在根目录下找到测试包,根目录下与包同级的其他目录中也不可以。

e.g. 考虑如下包结构。若 test1 和 test2 均通过绝对导入使用了 dev_pkg,则在根目录执行 python test1python testdir/test2 可行,进入 testdir/ 执行 python test2 不可行。

1
2
3
4
5
.
├── dev_pkg/ # your pkg
├── testdir/
│ └── test2.py
└── test1.py

考虑上一节目录结构中的 test/ 目录,该目录是不具备包结构的,无法使用相对路径导包。如果想调用模块进行测试,并要求切换工作目录到一个非根目录的路径,则 import 语句无法编写。

解决方案是 sys.path 动态导入包搜索路径。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# setuptools develop 模式下,pip list 只能在包源码同级目录找到包
# script path:
# $ tree
# .
# ├── your_pkg/ # top-level pkg
# ├── test/
# │ └── script.py
# └── setup.py

import sys

sys.path.append( \
os.path.join(os.path.dirname(__file__), '..'))

# absolute path
from your_pkg.subpkg import your_module

该导入会在脚本执行期间持续生效,因此只需放在顶层脚本中 (该脚本需要覆盖程序的整个生命周期),而不需要修改每一个文件。脚本执行结束时这个路径就失效了,不会影响其他程序执行。

快速迭代

由于模块间互相导入需要先打包安装,每次修改代码后都要 pip uninstall & python setup.py & pip uninstall,十分麻烦。

setuptools 提供 develop 选项简化开发过程。通过创建符号链接,将包安装到特定位置,而不会复制整个包。开发过程中对源代码的修改能够立即生效,不需要重新安装。

步骤如下:

1
2
3
# 没有系统目录的安装权限,则通过 --install-dir 指定,并添加到环境变量
export PYTHONPATH=$PYTHONPATH:./dev-pkgs
python3 setup.py develop --install-dir ./dev-pkgs

之后便可快速测试。注意:

  1. 此时只能在 setup.py 同级目录下找到该包,否则报 ModuleNotFound
  2. 此时 setup.entry_points 中注册的命令行工具无效

卸载时:

1
2
unset PYTHONPATH
rm -rf ./dev-pkgs

关于 import

以下两种方式均可

  1. from <file> import <member>
  2. from <module> import <file>

相对导入

具有模块结构的包 (with __init__.py) 可通过 . 以自身目录的相对路径导入其他包。

e.g., 考虑如下包结构

1
2
3
4
5
6
7
8
9
10
package/
__init__.py
subpackage1/
__init__.py
moduleX.py
moduleY.py
subpackage2/
__init__.py
moduleZ.py
moduleA.py

假设当前文件是moduleX.pysubpackage1/__init__.py,以下是正确用法:

1
2
3
4
5
6
7
8
from .moduleY import spam
from .moduleY import spam as ham
from . import moduleY
from ..subpackage1 import moduleY
from ..subpackage2.moduleZ import eggs
from ..moduleA import foo
from ...package import bar # Not recommended
from ...sys import path # Not recommended

相比绝对导入,相对导入的优势是包结构更加灵活可维护。

PyInstaller

主要参考 https://www.cnblogs.com/zhangxingcomeon/p/14523893.html

打包为单文件

Directory be like

1
2
3
4
$ tree
.
└── dist/
└── .exe

指定 -F 参数

1
pyinstaller -F main.py

打包为多文件

1
2
3
4
5
$ tree
.
└── dist/
└── .../
└── .exe

首先生成 .spec

1
2
pyi-makespec main.p
pyinstaller main.spec

Q & A

pip uninstall

将包的根目录作为工作目录时出现 pip uninstall 失效问题。进一步检查发现,该情形仅出现在设置 $PYTHONPATH 后。

1
2
3
# 找到了,就是不删
Found existing installation: your_pkg 0.1
Can't uninstall 'your_pkg'. No files were found to uninstall.

目前 Stackoverflow 上并无确切结论,但有数种解决方案。

  1. 进入 site-packages/ 手动删除 pkg 和 pkg.dist-info
  2. 退出包目录,执行 pip uninstall 生效
  3. 删除 ./.egg-info
  4. unset PYTHONPATH

目前看来 pip 无法在用户设置了 sys.path,即本例的 .dev-pkgs/ 的情况下卸载包 ( 为什么 ? )。保险起见,写 Makefile 时先 clean 再 uninstall,或确保正式 pip install 之前清除用于开发环境的 sys.path.

References

  1. 打包与分发 ( setuptools )
    1. 非常完整的教程 [Blog]
    2. ModuleNotFound
    3. 命令行工具
  2. 打包 .exe | PyInstaller
  3. 导包 | python3-cookbook.readthedocs.io