kkpack 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kkpack/__init__.py +21 -0
- kkpack/__main__.py +6 -0
- kkpack/analyze.py +232 -0
- kkpack/backends.py +396 -0
- kkpack/bootgen.py +249 -0
- kkpack/builder.py +655 -0
- kkpack/cli.py +341 -0
- kkpack/config.py +270 -0
- kkpack/deps.py +694 -0
- kkpack/peinfo.py +218 -0
- kkpack/runtime.py +1360 -0
- kkpack/signing.py +249 -0
- kkpack-0.1.0.dist-info/METADATA +756 -0
- kkpack-0.1.0.dist-info/RECORD +18 -0
- kkpack-0.1.0.dist-info/WHEEL +5 -0
- kkpack-0.1.0.dist-info/entry_points.txt +2 -0
- kkpack-0.1.0.dist-info/licenses/LICENSE +21 -0
- kkpack-0.1.0.dist-info/top_level.txt +1 -0
kkpack/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""kkpack —— 把 Python 项目打包成自带解释器的 exe,第三方依赖在首次运行时自动安装。
|
|
2
|
+
|
|
3
|
+
设计要点:
|
|
4
|
+
* 用户的 requests / numpy 这类第三方库不进 exe,exe 只带 Python 运行时和
|
|
5
|
+
用户自己的代码;第三方依赖在目标机首次启动时按 requirements.txt 安装。
|
|
6
|
+
* 目标机既不需要 Python,也不需要 pip。
|
|
7
|
+
* kkpack 自身零第三方依赖。
|
|
8
|
+
|
|
9
|
+
命令行:
|
|
10
|
+
kkpack main.py
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0"
|
|
14
|
+
|
|
15
|
+
__all__ = ["main", "__version__"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main(argv=None):
|
|
19
|
+
from .cli import main as _main
|
|
20
|
+
|
|
21
|
+
return _main(argv)
|
kkpack/__main__.py
ADDED
kkpack/analyze.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""源码分析:自动找出"开发者自己写的代码",使发起人不必手写打包清单。
|
|
2
|
+
|
|
3
|
+
kkpack 不需要用户声明要打包哪些模块 —— 后端(Nuitka / PyInstaller)本来就会
|
|
4
|
+
顺着 import 语句往下追。真正的问题是**追不到的部分**:
|
|
5
|
+
|
|
6
|
+
1. importlib.import_module("xxx") / __import__() 这类动态导入
|
|
7
|
+
2. 插件注册表:程序只在运行期才知道要加载哪个模块
|
|
8
|
+
|
|
9
|
+
本模块用 AST 把这些情况一并识别出来,产出显式的 include 清单交给后端,
|
|
10
|
+
从而兑现"读源码就能把开发者所有自有代码打包进去"。
|
|
11
|
+
|
|
12
|
+
同时对入口文件做体检,决定用哪种注入方式(见 bootgen)。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
SKIP_DIRS = {
|
|
19
|
+
".git", ".hg", ".svn", ".idea", ".vscode", "__pycache__", ".mypy_cache",
|
|
20
|
+
".pytest_cache", ".tox", "node_modules", "site-packages", "venv", ".venv",
|
|
21
|
+
"env", "build", "dist", ".deps_build", ".kkpack", "wheels", "offline",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
def iter_project_modules(root):
|
|
25
|
+
"""遍历项目目录,返回 {模块名: 绝对路径}。
|
|
26
|
+
|
|
27
|
+
目录里的每一个 .py 都被登记 —— 包(含 __init__.py)记为包名,
|
|
28
|
+
单文件模块记为模块名。虚拟环境、hidden 目录、构建产物目录一律跳过。
|
|
29
|
+
"""
|
|
30
|
+
modules = {}
|
|
31
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
32
|
+
dirnames[:] = [d for d in dirnames
|
|
33
|
+
if d not in SKIP_DIRS and not d.startswith(".")]
|
|
34
|
+
for name in filenames:
|
|
35
|
+
if not name.endswith(".py"):
|
|
36
|
+
continue
|
|
37
|
+
full = os.path.join(dirpath, name)
|
|
38
|
+
rel = os.path.relpath(full, root)
|
|
39
|
+
parts = rel[:-3].split(os.sep)
|
|
40
|
+
if not parts:
|
|
41
|
+
continue
|
|
42
|
+
if parts[-1] == "__init__":
|
|
43
|
+
parts = parts[:-1]
|
|
44
|
+
if not parts:
|
|
45
|
+
continue
|
|
46
|
+
dotted = ".".join(parts)
|
|
47
|
+
modules.setdefault(dotted, full)
|
|
48
|
+
return modules
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_STDLIB_NAMES = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _stdlib_names():
|
|
55
|
+
"""标准库模块名集合。
|
|
56
|
+
|
|
57
|
+
``sys.stdlib_module_names`` 是 3.10 才引入的;kkpack 声明支持 3.8+,
|
|
58
|
+
在 3.9 上它会退化成空集,于是 ``importlib.import_module("json")`` 被误报成
|
|
59
|
+
"动态导入未解析到本地文件"。这里补一个按标准库目录扫描的兜底。
|
|
60
|
+
|
|
61
|
+
方向上是安全的:这个集合只用来**抑制误报**,多收不会让任何东西被错误打包。
|
|
62
|
+
"""
|
|
63
|
+
global _STDLIB_NAMES
|
|
64
|
+
if _STDLIB_NAMES is not None:
|
|
65
|
+
return _STDLIB_NAMES
|
|
66
|
+
import sys
|
|
67
|
+
|
|
68
|
+
names = set(getattr(sys, "stdlib_module_names", ()) or ())
|
|
69
|
+
names |= set(getattr(sys, "builtin_module_names", ()) or ())
|
|
70
|
+
lib = None
|
|
71
|
+
try:
|
|
72
|
+
import sysconfig
|
|
73
|
+
|
|
74
|
+
lib = sysconfig.get_paths().get("stdlib")
|
|
75
|
+
except Exception: # noqa: BLE001
|
|
76
|
+
lib = None
|
|
77
|
+
lib = lib or os.path.dirname(os.__file__)
|
|
78
|
+
try:
|
|
79
|
+
for entry in os.listdir(lib):
|
|
80
|
+
if entry.endswith(".py"):
|
|
81
|
+
stem = entry[:-3]
|
|
82
|
+
if stem.isidentifier():
|
|
83
|
+
names.add(stem)
|
|
84
|
+
elif entry.isidentifier():
|
|
85
|
+
names.add(entry) # 包目录 / 扩展模块
|
|
86
|
+
except OSError:
|
|
87
|
+
pass
|
|
88
|
+
_STDLIB_NAMES = frozenset(names)
|
|
89
|
+
return _STDLIB_NAMES
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def scan_single_module(path):
|
|
93
|
+
"""扫描单个文件的 import,返回 (静态导入名集合, 相对层级集合, 动态导入名集合)。"""
|
|
94
|
+
static, relative, dynamic = set(), set(), set()
|
|
95
|
+
try:
|
|
96
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
97
|
+
source = fh.read()
|
|
98
|
+
tree = ast.parse(source)
|
|
99
|
+
except (OSError, SyntaxError, ValueError):
|
|
100
|
+
return static, relative, dynamic
|
|
101
|
+
|
|
102
|
+
def literal_str(node):
|
|
103
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
104
|
+
return node.value
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
for node in ast.walk(tree):
|
|
108
|
+
if isinstance(node, ast.Import):
|
|
109
|
+
for alias in node.names:
|
|
110
|
+
static.add(alias.name)
|
|
111
|
+
elif isinstance(node, ast.ImportFrom):
|
|
112
|
+
if node.level: # from . import x
|
|
113
|
+
relative.add(node.level)
|
|
114
|
+
if not node.module:
|
|
115
|
+
continue
|
|
116
|
+
static.add(node.module)
|
|
117
|
+
if node.level:
|
|
118
|
+
# 相对导入补不出完整点分路径,交给祖先包登记那一步处理
|
|
119
|
+
continue
|
|
120
|
+
# `from pkg import sub`:sub 很可能是**子模块**而不是属性。
|
|
121
|
+
# 不登记的话,没有 __init__.py 的目录结构(命名空间包)会整块漏掉 ——
|
|
122
|
+
# "pkg" 本身不是文件,闭包断在这里,目标机上直接 ImportError。
|
|
123
|
+
for alias in node.names:
|
|
124
|
+
if alias.name != "*":
|
|
125
|
+
static.add("%s.%s" % (node.module, alias.name))
|
|
126
|
+
elif isinstance(node, ast.Call):
|
|
127
|
+
func = node.func
|
|
128
|
+
dotted = None
|
|
129
|
+
if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
|
|
130
|
+
dotted = "%s.%s" % (func.value.id, func.attr)
|
|
131
|
+
elif isinstance(func, ast.Name):
|
|
132
|
+
dotted = func.id
|
|
133
|
+
if dotted in ("importlib.import_module", "__import__",
|
|
134
|
+
"importlib.util.find_spec", "pkgutil.iter_modules"):
|
|
135
|
+
if dotted != "pkgutil.iter_modules" and node.args:
|
|
136
|
+
name = literal_str(node.args[0])
|
|
137
|
+
if name:
|
|
138
|
+
dynamic.add(name)
|
|
139
|
+
return static, relative, dynamic
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def collect_local_modules(entry, root):
|
|
143
|
+
"""从入口文件出发做闭包扫描,返回 (本地模块名集合, 动态导入且未解析到的名字)。"""
|
|
144
|
+
modules = iter_project_modules(root)
|
|
145
|
+
entry_rel = os.path.relpath(os.path.abspath(entry), root)
|
|
146
|
+
entry_mod = entry_rel[:-3].replace(os.sep, ".")
|
|
147
|
+
if entry_mod.endswith(".__init__"):
|
|
148
|
+
entry_mod = entry_mod[: -len(".__init__")]
|
|
149
|
+
|
|
150
|
+
local, unresolved = set(), set()
|
|
151
|
+
queue = [entry_mod]
|
|
152
|
+
seen = set()
|
|
153
|
+
while queue:
|
|
154
|
+
current = queue.pop()
|
|
155
|
+
if current in seen:
|
|
156
|
+
continue
|
|
157
|
+
seen.add(current)
|
|
158
|
+
path = modules.get(current)
|
|
159
|
+
if path is None:
|
|
160
|
+
continue
|
|
161
|
+
local.add(current)
|
|
162
|
+
# 连同祖先包一起登记:顶级包会用 --include-package 收拢整棵子树,
|
|
163
|
+
# 既不用逐个列举子模块,也覆盖了运行期才知道名字的动态子导入
|
|
164
|
+
parts = current.split(".")
|
|
165
|
+
for size in range(1, len(parts)):
|
|
166
|
+
ancestor = ".".join(parts[:size])
|
|
167
|
+
if ancestor in modules:
|
|
168
|
+
local.add(ancestor)
|
|
169
|
+
queue.append(ancestor)
|
|
170
|
+
static, _rel, dynamic = scan_single_module(path)
|
|
171
|
+
for name in list(static) + list(dynamic):
|
|
172
|
+
parts = name.split(".")
|
|
173
|
+
for size in range(len(parts), 0, -1):
|
|
174
|
+
candidate = ".".join(parts[:size])
|
|
175
|
+
if candidate in modules:
|
|
176
|
+
queue.append(candidate)
|
|
177
|
+
break
|
|
178
|
+
else:
|
|
179
|
+
if name in dynamic and parts[0] not in _stdlib_names():
|
|
180
|
+
unresolved.add(name)
|
|
181
|
+
return local, unresolved
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def split_packages_and_modules(root, names):
|
|
185
|
+
"""区分包与单模块:后端对两者用的参数不同(Nuitka 尤甚)。
|
|
186
|
+
|
|
187
|
+
包要用 --include-package 才会把子模块一并收进去,只给 --include-module
|
|
188
|
+
会得到"只有 __init__.py 被打包"的结果。
|
|
189
|
+
"""
|
|
190
|
+
pkgs, mods = [], []
|
|
191
|
+
for name in sorted(names):
|
|
192
|
+
parts = name.split(".")
|
|
193
|
+
# 包 = 目录里有 __init__.py。这种必须交给 --include-package,
|
|
194
|
+
# 只给 --include-module 会得到"只有 __init__.py 被打包"的结果。
|
|
195
|
+
if os.path.exists(os.path.join(root, *parts, "__init__.py")):
|
|
196
|
+
pkgs.append(parts[0])
|
|
197
|
+
else:
|
|
198
|
+
mods.append(name)
|
|
199
|
+
pkgs = sorted(set(pkgs))
|
|
200
|
+
mods = sorted(set(m for m in mods if m.split(".")[0] not in pkgs))
|
|
201
|
+
return pkgs, mods
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def inspect_entry(entry):
|
|
205
|
+
"""给入口文件做体检,决定用哪种方式注入运行期逻辑。
|
|
206
|
+
|
|
207
|
+
返回 dict:
|
|
208
|
+
relative : 是否含相对导入(from . import x)—— 这种文件不能被内联到
|
|
209
|
+
顶层执行,必须走"模块模式"
|
|
210
|
+
sentinel : 是否有 if __name__ == "__main__" 守卫
|
|
211
|
+
syntax_ok: 语法是否可被解析
|
|
212
|
+
"""
|
|
213
|
+
info = {"relative": False, "sentinel": False, "syntax_ok": True}
|
|
214
|
+
try:
|
|
215
|
+
with open(entry, "r", encoding="utf-8") as fh:
|
|
216
|
+
source = fh.read()
|
|
217
|
+
tree = ast.parse(source)
|
|
218
|
+
except (OSError, SyntaxError, ValueError) as exc:
|
|
219
|
+
info["syntax_ok"] = False
|
|
220
|
+
info["error"] = str(exc)
|
|
221
|
+
return info
|
|
222
|
+
|
|
223
|
+
sentinel, relative = False, False
|
|
224
|
+
for node in ast.walk(tree):
|
|
225
|
+
if isinstance(node, ast.ImportFrom) and node.level:
|
|
226
|
+
relative = True
|
|
227
|
+
if isinstance(node, ast.If):
|
|
228
|
+
left = getattr(node.test, "left", None)
|
|
229
|
+
if isinstance(left, ast.Name) and left.id == "__name__":
|
|
230
|
+
sentinel = True
|
|
231
|
+
info["relative"], info["sentinel"] = relative, sentinel
|
|
232
|
+
return info
|
kkpack/backends.py
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""后端命令构造:Nuitka / PyInstaller。
|
|
2
|
+
|
|
3
|
+
这里集中了几个"只有真跑过才知道"的关键参数,注释里都写了原因。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import ast
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from . import peinfo
|
|
11
|
+
|
|
12
|
+
# 原则是"标准库一律打进 exe":tkinter / turtle / sqlite3 / asyncio 全部在内。
|
|
13
|
+
# 下面这些是唯一仍在排除之列的 —— 要么会让 Nuitka 编译期内部崩溃,要么纯粹是
|
|
14
|
+
# 开发工具,进去只有体积代价没有收益。注意 lib2to3 之所以保留在列表里,
|
|
15
|
+
# 是因为它依赖 test.support,而 test.support 在多条 Nuitka 版本上都有崩溃记录。
|
|
16
|
+
SKIP_STDLIB = {
|
|
17
|
+
"test", "tests", "lib2to3", "__pycache__", "site-packages", "antigravity",
|
|
18
|
+
"ensurepip", "venv",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
# 平台相关:Windows 的 CPython 里有 curses 源码但没有底层 _curses 扩展,
|
|
22
|
+
# 强行 include 只会得到一条"无法处理"的错误。
|
|
23
|
+
SKIP_STDLIB_WIN32 = {"curses"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _skip_set():
|
|
27
|
+
names = set(SKIP_STDLIB)
|
|
28
|
+
if sys.platform == "win32":
|
|
29
|
+
names |= SKIP_STDLIB_WIN32
|
|
30
|
+
return names
|
|
31
|
+
|
|
32
|
+
# AST 抓不到的动态 import / C 扩展的间接依赖,缺一个就连环崩,这里兜底。
|
|
33
|
+
FALLBACK_STDLIB = {
|
|
34
|
+
"abc", "argparse", "base64", "binascii", "bisect", "bz2", "calendar", "cmath",
|
|
35
|
+
"codecs", "collections", "concurrent", "configparser", "contextlib", "copy",
|
|
36
|
+
"copyreg", "csv", "ctypes", "dataclasses", "datetime", "decimal", "difflib",
|
|
37
|
+
"email", "encodings", "enum", "errno", "fnmatch", "fractions", "functools",
|
|
38
|
+
"getopt", "getpass", "gettext", "glob", "gzip", "hashlib", "heapq", "hmac",
|
|
39
|
+
"html", "http", "importlib", "inspect", "io", "ipaddress", "itertools", "json",
|
|
40
|
+
"linecache", "locale", "logging", "lzma", "math", "mimetypes", "mmap", "netrc",
|
|
41
|
+
"numbers", "operator", "os", "pathlib", "pickle", "pkgutil", "platform", "pprint",
|
|
42
|
+
"queue", "random", "re", "secrets", "select", "selectors", "shlex", "shutil",
|
|
43
|
+
# 注意:不要放 sqlite3 —— 它会把 sqlite3.test / test.support 拖进来,
|
|
44
|
+
# 某些 Nuitka 版本编译 test.support 时会内部崩溃
|
|
45
|
+
"signal", "socket", "socketserver", "ssl", "stat", "statistics",
|
|
46
|
+
"string", "struct", "subprocess", "sysconfig", "tarfile", "tempfile", "textwrap",
|
|
47
|
+
"threading", "time", "tokenize", "traceback", "types", "typing", "unicodedata",
|
|
48
|
+
"urllib", "uuid", "warnings", "weakref", "xml", "zipfile", "zlib",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def stdlib_names():
|
|
53
|
+
lib = os.path.dirname(os.__file__)
|
|
54
|
+
skip = _skip_set()
|
|
55
|
+
# built-in 名字也收进来:AST 扫描时要能把 `import time` 认成标准库。
|
|
56
|
+
# 但真正发给后端的列表会在 split_mods_pkgs 里把纯 built-in 摘掉 —— 它们
|
|
57
|
+
# 没有文件可 include,且旧版 Nuitka 收到就崩(见 split_mods_pkgs 注释)
|
|
58
|
+
names = set(sys.builtin_module_names)
|
|
59
|
+
for entry in os.listdir(lib):
|
|
60
|
+
if entry.endswith(".py"):
|
|
61
|
+
name = entry[:-3]
|
|
62
|
+
# 必须先去掉扩展名再比 skip —— 拿 "antigravity.py" 去比 "antigravity"
|
|
63
|
+
# 永远不命中,skip 名单对单文件模块整体失效
|
|
64
|
+
if name in skip:
|
|
65
|
+
continue
|
|
66
|
+
if name.isidentifier(): # 排除 __phello__.foo 这类非法模块名的文件
|
|
67
|
+
names.add(name)
|
|
68
|
+
continue
|
|
69
|
+
if entry in skip:
|
|
70
|
+
continue
|
|
71
|
+
if entry.isidentifier() and os.path.exists(os.path.join(lib, entry, "__init__.py")):
|
|
72
|
+
names.add(entry)
|
|
73
|
+
return names
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def stdlib_test_submodules():
|
|
77
|
+
"""标准库里所有 <pkg>.test 子包。
|
|
78
|
+
|
|
79
|
+
全量包含标准库时必须显式排除它们:sqlite3.test / unittest.test 会引入
|
|
80
|
+
test.support,而多个 Nuitka 版本编译 test.support 时都有内部崩溃记录。
|
|
81
|
+
"""
|
|
82
|
+
lib = os.path.dirname(os.__file__)
|
|
83
|
+
out = []
|
|
84
|
+
for entry in sorted(os.listdir(lib)):
|
|
85
|
+
if entry in _skip_set():
|
|
86
|
+
continue
|
|
87
|
+
pkg_dir = os.path.join(lib, entry)
|
|
88
|
+
if os.path.isdir(pkg_dir) and os.path.exists(
|
|
89
|
+
os.path.join(pkg_dir, "test", "__init__.py")):
|
|
90
|
+
out.append("%s.test" % entry)
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def is_builtin_or_frozen(name):
|
|
95
|
+
"""这个名字是不是由解释器本体提供(built-in 或 frozen),即没有文件可 include。
|
|
96
|
+
|
|
97
|
+
判据与 Nuitka 自己的 `isBuiltinModuleName()` 同源:
|
|
98
|
+
|
|
99
|
+
_imp.is_builtin(name) or _imp.is_frozen(name)
|
|
100
|
+
|
|
101
|
+
为什么不能只看"Lib 里有没有 <name>.py":CPython 3.9 上 **zipimport 是
|
|
102
|
+
frozen 模块**,`Lib/zipimport.py` 确实躺在磁盘上,但解释器用的是冻结版 ——
|
|
103
|
+
Nuitka 照样把它判成 built-in(日志原文:"Note, module 'zipimport' that you
|
|
104
|
+
asked to include is built-in.")。按"有没有文件"筛会把它漏过去,旧版
|
|
105
|
+
Nuitka 依然崩。
|
|
106
|
+
"""
|
|
107
|
+
try:
|
|
108
|
+
import _imp
|
|
109
|
+
except ImportError: # pragma: no cover - CPython 一定有
|
|
110
|
+
return name in sys.builtin_module_names
|
|
111
|
+
|
|
112
|
+
mk_is_builtin = getattr(_imp, "is_builtin", None)
|
|
113
|
+
mk_is_frozen = getattr(_imp, "is_frozen", None)
|
|
114
|
+
if mk_is_builtin is None or mk_is_frozen is None: # pragma: no cover
|
|
115
|
+
return name in sys.builtin_module_names
|
|
116
|
+
|
|
117
|
+
return bool(mk_is_builtin(name)) or bool(mk_is_frozen(name))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def builtin_modules(names=None):
|
|
121
|
+
"""候选名单里由解释器本体提供的模块(built-in + frozen)。
|
|
122
|
+
|
|
123
|
+
给它们发 `--include-module` 不但没意义(本来就编在解释器里),
|
|
124
|
+
还会让旧版 Nuitka 在解析 include 列表时内部崩溃。
|
|
125
|
+
|
|
126
|
+
不传 `names` 就按全量标准库名统计,用于报数:Python 3.9 上是 68 个
|
|
127
|
+
(67 个 `sys.builtin_module_names` + frozen 的 zipimport)。
|
|
128
|
+
"""
|
|
129
|
+
if names is None:
|
|
130
|
+
names = stdlib_names()
|
|
131
|
+
return {name for name in names if is_builtin_or_frozen(name)}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def split_mods_pkgs(names):
|
|
135
|
+
"""区分单模块与包:两者对后端要用不同的参数,混用会漏掉子模块。
|
|
136
|
+
|
|
137
|
+
顺带把 built-in / frozen 模块(_abc / _winapi / sys / time / zlib / zipimport …)
|
|
138
|
+
摘掉:它们由解释器本体提供,没有可 include 的文件。
|
|
139
|
+
而 Nuitka 2.3.2 收到 `--include-module=<built-in>` 会在解析 include 列表时
|
|
140
|
+
直接内部崩溃:
|
|
141
|
+
|
|
142
|
+
Recursion.checkPluginSinglePath(plugin_filename=None)
|
|
143
|
+
-> os.path.abspath(None)
|
|
144
|
+
-> TypeError: _getfullpathname: path should be string, bytes or
|
|
145
|
+
os.PathLike, not NoneType
|
|
146
|
+
|
|
147
|
+
Nuitka 2.7.13 起才加了 module_kind == "built-in" 的判断,改成只警告
|
|
148
|
+
"Note, module X that you asked to include is built-in."。所以想在旧 Nuitka
|
|
149
|
+
上也能打包,就必须在构造参数前把它们摘掉(摘掉后产物完全不变,
|
|
150
|
+
built-in 本来就在解释器里)。
|
|
151
|
+
"""
|
|
152
|
+
skip = _skip_set()
|
|
153
|
+
lib = os.path.dirname(os.__file__)
|
|
154
|
+
builtin = builtin_modules(names)
|
|
155
|
+
mods, pkgs = [], []
|
|
156
|
+
for name in sorted(names):
|
|
157
|
+
if name in skip:
|
|
158
|
+
continue
|
|
159
|
+
pkg_dir = os.path.join(lib, name)
|
|
160
|
+
if os.path.isdir(pkg_dir) and os.path.exists(os.path.join(pkg_dir, "__init__.py")):
|
|
161
|
+
pkgs.append(name)
|
|
162
|
+
continue
|
|
163
|
+
if name in builtin:
|
|
164
|
+
continue
|
|
165
|
+
mods.append(name)
|
|
166
|
+
return mods, pkgs
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def ast_stdlib_modules(wheels_dir, wheel_files):
|
|
170
|
+
"""精确模式:AST 扫描"运行时才装"的 wheel,只收它们真正 import 的标准库模块。
|
|
171
|
+
|
|
172
|
+
为什么必须做这件事:第三方库被 --nofollow-import-to 排除后,后端就看不见它们
|
|
173
|
+
import 的标准库模块了。实测 requests 会缺 hmac、urllib3 会缺 http.cookies,
|
|
174
|
+
补一个又冒出下一个 —— 连锁崩溃。
|
|
175
|
+
"""
|
|
176
|
+
known = stdlib_names()
|
|
177
|
+
found = set()
|
|
178
|
+
for filename in wheel_files:
|
|
179
|
+
path = os.path.join(wheels_dir, filename)
|
|
180
|
+
try:
|
|
181
|
+
import zipfile
|
|
182
|
+
|
|
183
|
+
with zipfile.ZipFile(path) as zf:
|
|
184
|
+
names = [n for n in zf.namelist() if n.endswith(".py")]
|
|
185
|
+
sources = [(n, zf.read(n)) for n in names]
|
|
186
|
+
except Exception: # noqa: BLE001
|
|
187
|
+
continue
|
|
188
|
+
for _name, raw in sources:
|
|
189
|
+
try:
|
|
190
|
+
tree = ast.parse(raw)
|
|
191
|
+
except (SyntaxError, ValueError):
|
|
192
|
+
continue
|
|
193
|
+
for node in ast.walk(tree):
|
|
194
|
+
if isinstance(node, ast.Import):
|
|
195
|
+
for alias in node.names:
|
|
196
|
+
top = alias.name.split(".")[0]
|
|
197
|
+
if top in known:
|
|
198
|
+
found.add(top)
|
|
199
|
+
elif isinstance(node, ast.ImportFrom) and node.module and not node.level:
|
|
200
|
+
top = node.module.split(".")[0]
|
|
201
|
+
if top in known:
|
|
202
|
+
found.add(top)
|
|
203
|
+
return found
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def stdlib_targets(cfg, wheels_dir, wheel_files):
|
|
207
|
+
mode = cfg["stdlib_mode"]
|
|
208
|
+
if mode == "none":
|
|
209
|
+
return [], []
|
|
210
|
+
if mode == "full":
|
|
211
|
+
return split_mods_pkgs(stdlib_names())
|
|
212
|
+
scanned = ast_stdlib_modules(wheels_dir, wheel_files)
|
|
213
|
+
total = scanned | FALLBACK_STDLIB | set(cfg["stdlib_extra"])
|
|
214
|
+
return split_mods_pkgs(total)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def non_ascii_build_paths(ctx):
|
|
218
|
+
"""构建路径里含非 ASCII 字符的那些(中文目录)。
|
|
219
|
+
|
|
220
|
+
这类路径会让 ccache 的日志变成非 UTF-8:ccache 是原生程序,日志按系统
|
|
221
|
+
ANSI 代码页写(中文 Windows = GBK),而 Nuitka 2.3.2 用 UTF-8 读它
|
|
222
|
+
(`build/SconsCaching._getCcacheStatistics`),于是**编译成功之后**在收尾
|
|
223
|
+
统计时崩:
|
|
224
|
+
|
|
225
|
+
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xd7 ...
|
|
226
|
+
|
|
227
|
+
实测 `D:\\桌面文件夹\\...` 必现(日志里原样写着这个路径的 GBK 字节)。
|
|
228
|
+
"""
|
|
229
|
+
candidates = [
|
|
230
|
+
ctx.get("project_root"), ctx.get("entry_path"),
|
|
231
|
+
ctx["cfg"].get("output_dir"), sys.prefix,
|
|
232
|
+
getattr(sys, "base_prefix", None),
|
|
233
|
+
]
|
|
234
|
+
return [p for p in candidates if p and any(ord(ch) > 127 for ch in p)]
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def python3_dll():
|
|
238
|
+
"""构建机上 CPython 稳定 ABI 转发 DLL(`python3.dll`)的绝对路径;没有则 None。
|
|
239
|
+
|
|
240
|
+
为什么必须随产物带过去:
|
|
241
|
+
Windows 上 abi3 轮子(PySide6 / shiboken6 都是 `cp39-abi3`)的扩展链接的是
|
|
242
|
+
**`python3.dll`**(版本无关的转发层),而不是 `python39.dll`。这类包被
|
|
243
|
+
`--nofollow-import-to` 排除在编译之外、改成运行时安装,后端因此根本看不到
|
|
244
|
+
它们的 DLL 依赖,也就不会把 `python3.dll` 收进产物;而目标机上没有 Python,
|
|
245
|
+
更不会凭空出现这个文件。实测症状:
|
|
246
|
+
|
|
247
|
+
ImportError: DLL load failed while importing Shiboken: 找不到指定的模块。
|
|
248
|
+
|
|
249
|
+
读 PE 导入表可确认:`shiboken6/Shiboken.pyd` 与 `shiboken6/shiboken6.abi3.dll`
|
|
250
|
+
的导入表里都写着 `python3.dll`。把文件补到 exe 同级后窗口即正常显示。
|
|
251
|
+
|
|
252
|
+
位置在 Python 安装根目录(venv 里则在 base 前缀),`sys.base_prefix` 优先。
|
|
253
|
+
"""
|
|
254
|
+
if sys.platform != "win32":
|
|
255
|
+
return None
|
|
256
|
+
for base in (getattr(sys, "base_prefix", None), sys.prefix):
|
|
257
|
+
if not base:
|
|
258
|
+
continue
|
|
259
|
+
candidate = os.path.join(base, "python3.dll")
|
|
260
|
+
if os.path.isfile(candidate):
|
|
261
|
+
return candidate
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def nuitka_cmd(ctx):
|
|
266
|
+
"""Nuitka 命令。
|
|
267
|
+
|
|
268
|
+
console=False 时必须加 --windows-disable-console:Nuitka 默认继承控制台子系统,
|
|
269
|
+
GUI 程序跑起来会先闪一个黑窗口,且关不掉。
|
|
270
|
+
"""
|
|
271
|
+
cfg = ctx["cfg"]
|
|
272
|
+
cmd = [
|
|
273
|
+
ctx["python"], "-m", "nuitka",
|
|
274
|
+
"--onefile" if cfg["onefile"] else "--standalone",
|
|
275
|
+
]
|
|
276
|
+
if not cfg.get("console", True) and sys.platform == "win32":
|
|
277
|
+
# GUI 模式:没有黑窗口后 print 全部不可见,首次安装依赖的反馈
|
|
278
|
+
# 只能靠运行期的 tkinter 进度条,别把它也关了
|
|
279
|
+
cmd.append("--windows-disable-console")
|
|
280
|
+
# 不加这个,_tkinter.pyd 虽然编进去了,import tkinter 依然报
|
|
281
|
+
# "Need to use '--enable-plugin=tk-inter'",进度条和用户的 GUI 都废了
|
|
282
|
+
if cfg.get("tkinter", True) and cfg["stdlib_mode"] != "none":
|
|
283
|
+
cmd.append("--enable-plugin=tk-inter")
|
|
284
|
+
icon = ctx.get("icon")
|
|
285
|
+
if icon and sys.platform == "win32":
|
|
286
|
+
cmd.append("--windows-icon-from-ico=%s" % icon)
|
|
287
|
+
# 版本资源:没有公司名 / 版本号的未签名 exe 是安全软件的重点怀疑对象。
|
|
288
|
+
# Nuitka 不写这一栏(--product-name 的帮助里那句"Defaults to base filename"
|
|
289
|
+
# 只在给了字符串时才生效),所以要显式传,见 peinfo 模块注释
|
|
290
|
+
cmd += peinfo.nuitka_flags(ctx.get("version_info"))
|
|
291
|
+
dll = python3_dll()
|
|
292
|
+
if dll:
|
|
293
|
+
# abi3 轮子(PySide6 / shiboken6)要它才能加载,见 python3_dll() 注释
|
|
294
|
+
cmd.append("--include-data-files=%s=python3.dll" % dll)
|
|
295
|
+
if non_ascii_build_paths(ctx):
|
|
296
|
+
# 中文路径下 ccache 会写出非 UTF-8 日志,Nuitka 收尾统计时崩
|
|
297
|
+
# —— 关掉它就没有 CCACHE_LOGFILE,那段逻辑整块跳过(见函数注释)
|
|
298
|
+
cmd.append("--disable-ccache")
|
|
299
|
+
cmd += [
|
|
300
|
+
"--mingw64" if ctx["compiler"] == "mingw64" else "--msvc=latest",
|
|
301
|
+
"--assume-yes-for-downloads",
|
|
302
|
+
"--jobs=%d" % cfg["jobs"], # 限制并行,降低 C 编译峰值内存
|
|
303
|
+
# LTO 必须关:include 模块变多后,链接阶段会爆内存而中途被杀
|
|
304
|
+
"--lto=%s" % ("yes" if cfg["lto"] else "no"),
|
|
305
|
+
# 别把 unittest / setuptools / pytest 那一坨拖进来:既拖慢编译,
|
|
306
|
+
# 也容易触发 Nuitka 内部崩溃
|
|
307
|
+
"--noinclude-unittest-mode=nofollow",
|
|
308
|
+
"--noinclude-setuptools-mode=nofollow",
|
|
309
|
+
"--noinclude-pytest-mode=nofollow",
|
|
310
|
+
"--include-module=%s" % ctx["manifest_module"],
|
|
311
|
+
"--include-module=%s" % ctx["runtime_module"],
|
|
312
|
+
"--output-dir=%s" % cfg["output_dir"],
|
|
313
|
+
]
|
|
314
|
+
body_mod = ctx.get("entry_body_module")
|
|
315
|
+
if body_mod:
|
|
316
|
+
cmd.append("--include-module=%s" % body_mod)
|
|
317
|
+
for pkg in ctx["local_packages"]:
|
|
318
|
+
cmd.append("--include-package=%s" % pkg) # 包必须这样收,否则只有 __init__.py
|
|
319
|
+
for mod in ctx["local_modules"]:
|
|
320
|
+
cmd.append("--include-module=%s" % mod)
|
|
321
|
+
if cfg["stdlib_mode"] != "none":
|
|
322
|
+
mods, pkgs = ctx["stdlib_targets"]
|
|
323
|
+
cmd += ["--include-module=%s" % m for m in mods]
|
|
324
|
+
cmd += ["--include-package=%s" % p for p in pkgs]
|
|
325
|
+
cmd += ["--nofollow-import-to=%s" % t for t in ctx.get("stdlib_tests", [])]
|
|
326
|
+
for mod in sorted(ctx["bundled_modules"]):
|
|
327
|
+
cmd.append("--include-package=%s" % mod)
|
|
328
|
+
for mod in sorted(ctx["runtime_modules"]):
|
|
329
|
+
cmd.append("--nofollow-import-to=%s" % mod)
|
|
330
|
+
cmd.append(ctx["entry_path"])
|
|
331
|
+
# 后端产物名由主模块名决定,用 module_stem;用户要的产物名在编译后改名
|
|
332
|
+
stem = ctx.get("module_stem") or ctx["stem"]
|
|
333
|
+
if cfg["onefile"]:
|
|
334
|
+
artifact = os.path.join(cfg["output_dir"], stem + ".exe")
|
|
335
|
+
else:
|
|
336
|
+
artifact = os.path.join(cfg["output_dir"], stem + ".dist", stem + ".exe")
|
|
337
|
+
return cmd, artifact
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def pyinstaller_cmd(ctx):
|
|
341
|
+
cfg = ctx["cfg"]
|
|
342
|
+
cmd = [
|
|
343
|
+
ctx["python"], "-m", "PyInstaller",
|
|
344
|
+
"--onefile" if cfg["onefile"] else "--onedir",
|
|
345
|
+
]
|
|
346
|
+
# PyInstaller 的 --noconsole 只在 Windows / macOS 有意义,Linux 上会被拒绝
|
|
347
|
+
if not cfg.get("console", True) and sys.platform in ("win32", "darwin"):
|
|
348
|
+
cmd.append("--noconsole")
|
|
349
|
+
icon = ctx.get("icon")
|
|
350
|
+
if icon and sys.platform == "win32":
|
|
351
|
+
cmd.append("--icon=%s" % icon)
|
|
352
|
+
# 版本资源走一个文件:PyInstaller 只认 --version-file 里的 VSVersionInfo 结构
|
|
353
|
+
version_file = ctx.get("version_file")
|
|
354
|
+
if version_file and sys.platform == "win32":
|
|
355
|
+
cmd.append("--version-file=%s" % version_file)
|
|
356
|
+
dll = python3_dll()
|
|
357
|
+
if dll:
|
|
358
|
+
# 同上:目标机上没有 Python,abi3 扩展靠它才能加载
|
|
359
|
+
cmd.append("--add-data=%s%s." % (dll, os.pathsep))
|
|
360
|
+
cmd += [
|
|
361
|
+
"--noconfirm", "--clean",
|
|
362
|
+
"--distpath", os.path.join(cfg["output_dir"], "dist"),
|
|
363
|
+
"--workpath", os.path.join(cfg["output_dir"], "build"),
|
|
364
|
+
"--specpath", cfg["output_dir"],
|
|
365
|
+
"--hidden-import=%s" % ctx["manifest_module"],
|
|
366
|
+
"--hidden-import=%s" % ctx["runtime_module"],
|
|
367
|
+
]
|
|
368
|
+
body_mod = ctx.get("entry_body_module")
|
|
369
|
+
if body_mod:
|
|
370
|
+
cmd.append("--hidden-import=%s" % body_mod)
|
|
371
|
+
if ctx["bundled_files"]:
|
|
372
|
+
cmd += ["--paths", ctx["deps_build"]]
|
|
373
|
+
for mod in ctx["local_modules"]:
|
|
374
|
+
cmd.append("--hidden-import=%s" % mod)
|
|
375
|
+
for pkg in ctx["local_packages"]:
|
|
376
|
+
# 必须用 collect-submodules:--hidden-import 只收包本身,
|
|
377
|
+
# 不递归子模块,于是很容易挂在没被打包的子模块上
|
|
378
|
+
cmd.append("--collect-submodules=%s" % pkg)
|
|
379
|
+
if cfg["stdlib_mode"] != "none":
|
|
380
|
+
mods, pkgs = ctx["stdlib_targets"]
|
|
381
|
+
cmd += ["--hidden-import=%s" % m for m in mods]
|
|
382
|
+
cmd += ["--collect-submodules=%s" % p for p in pkgs]
|
|
383
|
+
# 和 Nuitka 一样必须挡住 <pkg>.test:sqlite3.test -> test.support
|
|
384
|
+
# 是已知会把打包器拖崩/拖胖的组合
|
|
385
|
+
cmd += ["--exclude-module=%s" % t for t in ctx.get("stdlib_tests", [])]
|
|
386
|
+
for mod in sorted(ctx["bundled_modules"]):
|
|
387
|
+
cmd.append("--collect-submodules=%s" % mod)
|
|
388
|
+
for mod in sorted(ctx["runtime_modules"]):
|
|
389
|
+
cmd.append("--exclude-module=%s" % mod)
|
|
390
|
+
cmd.append(ctx["entry_path"])
|
|
391
|
+
stem = ctx.get("module_stem") or ctx["stem"]
|
|
392
|
+
if cfg["onefile"]:
|
|
393
|
+
artifact = os.path.join(cfg["output_dir"], "dist", stem + ".exe")
|
|
394
|
+
else:
|
|
395
|
+
artifact = os.path.join(cfg["output_dir"], "dist", stem, stem + ".exe")
|
|
396
|
+
return cmd, artifact
|