Metadata-Version: 2.4
Name: kkpack
Version: 0.1.0
Summary: 把 Python 项目打包成自带解释器的 exe，第三方依赖在首次运行时自动安装
Author: Python卡皮巴拉
License-Expression: MIT
Keywords: packaging,exe,nuitka,pyinstaller,freeze,windows,kkpack
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Natural Language :: Chinese (Simplified)
Classifier: Operating System :: Microsoft :: Windows
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Build Tools
Classifier: Topic :: System :: Software Distribution
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Provides-Extra: release
Requires-Dist: build>=1.0; extra == "release"
Requires-Dist: twine>=5.0; extra == "release"
Dynamic: license-file

# kkpack

把 Python 项目打包成 **Windows 上自带解释器的 exe**，第三方依赖在目标机首次运行时按需安装。

```bash
pip install kkpack
kkpack main.py
```

使用者电脑上不需要装 Python，也不需要装任何依赖。

---

## 它解决什么问题

传统打包（Nuitka / PyInstaller 一把梭）有两个痛点：

- **体积**：把 numpy、torch 这类重型库全编进 exe，产物动辄几百 MB，编译半小时起步。
- **更新**：改一行代码就要重新编译整个依赖树。

kkpack 的做法是：**你的代码 + Python 运行时 + 标准库编进 exe，第三方依赖留到运行时按需安装。**
依赖版本在打包时冻结成一份完整清单（含间接依赖），所以目标机装出来的版本永远和你开发时一致。

---

## 30 秒上手

假设你的项目长这样（就是最普通的写法，不需要为打包改任何代码）：

```
myapp/
├── main.py
├── mypkg/
│   ├── __init__.py
│   └── core.py
└── requirements.txt      # requests==2.31.0
```

**第 1 步：安装 kkpack**

```bash
pip install kkpack
```

**第 2 步：确认环境**（可选，但第一次用建议跑一下）

```bash
cd myapp
kkpack doctor
```

它会告诉你：有没有 C 编译器、后端装没装、`requirements.txt` 在不在，以及哪些依赖没写版本、
哪些用了范围约束（两者都允许，只是打包时取到的版本不同）。

**第 3 步：打包**

```bash
kkpack main.py
```

**第 4 步：把产物整个目录拷给别人**

```
dist/main.dist/main.exe          # 双击即可运行
dist/main.dist/requirements.txt  # 依赖清单，随程序分发
```

`main.exe` 首次运行会把依赖装到 exe 同级的 `_deps/` 目录里，之后每次启动都不再联网。
**注意：`main.dist` 整个目录要一起拷，不能只拷 exe。**

---

## 命令行

```
kkpack [入口文件] [选项]
kkpack init           生成带注释的配置文件
kkpack doctor         检查当前环境的打包能力
```

| 参数 | 作用 | 默认 |
|---|---|---|
| `entry` | 入口 py 文件，如 `main.py`（不写则从配置读 `tool.entry`） | — |
| `--backend nuitka\|pyinstaller` | 打包后端，没装会自动 pip 安装 | `nuitka` |
| `--mode runtime\|offline\|all` | 依赖处理方式 | `runtime` |
| `--onefile` / `--no-onefile` | 单 exe / 目录形式（目录启动更快，推荐） | 目录形式 |
| `--windowed` / `--console` | 是否显示控制台黑窗口（GUI 程序用 `--windowed`） | `--console` |
| `--out DIR` | 产物输出目录 | `dist` |
| `--exe-name NAME` | 产物（exe）名，不用写 `.exe` | 入口文件名 |
| `--icon PATH.ico` | 程序图标，只支持 `.ico` | 系统默认图标 |
| `--index URL` | 追加镜像源，可重复（`--index A --index B`） | 阿里云→清华→PyPI |
| `--stdlib precise\|full\|none` | 标准库包含策略 | `full` |
| `--jobs N` | 并行编译进程数，内存小就调小 | `2` |
| `--callable NAME` | 模块模式下要调用的函数名 | — |
| `-c, --config PATH` | 指定配置文件（默认自动找 `kkpack.toml` / `pyfrost.toml`） | 自动查找 |
| `--clean` | 清空自动生成的构建文件后重编（保留 wheel 下载缓存） | — |
| `--quiet` | 只输出关键结果 | — |

配置文件的优先级：**命令行参数 > 配置文件 > 默认值**。

---

## 配置文件（可选，不写也能跑）

```bash
kkpack init          # 生成一份带注释的样板
```

默认查找当前目录下的 `kkpack.toml`（本工具前身用过的 `pyfrost.toml` 同样接受）。
完整配置如下，**全部可以省略**：

```toml
[tool]
backend = "nuitka"        # nuitka | pyinstaller
entry = "main.py"         # 命令行给了入口就以命令行优先
exe_name = "MyApp"        # 产物（exe）名，不用写 .exe；默认取入口文件名
icon = "assets/app.ico"   # 程序图标，只支持 .ico；不写用系统默认图标
onefile = false           # 目录形式启动更快
console = true            # GUI 程序改成 false，不弹黑窗口
output_dir = "dist"
jobs = 2                  # 并行编译数，内存不够就调小

[bundle]
# runtime  首次运行联网安装依赖（体积最小，推荐）
# offline  把 wheel 随程序分发，使用者完全不需要联网
# all      依赖全部编译进 exe（体积大、编译久，完全自包含）
mode = "runtime"
include = []              # 强制编译进 exe 的包
exclude = []              # 强制留到运行时的包

[runtime]
check_update = false      # 是否在运行时检查依赖更新（会联网）
progress = true           # 首次安装时弹 tkinter 进度条
download_jobs = 4         # 并发下载数

[index]
urls = [
    "https://mirrors.aliyun.com/pypi/simple/",
    "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/",
    "https://pypi.org/simple/",
]
timeout = 30

[stdlib]
# full（默认）：整个标准库全打进 exe，含 tkinter / sqlite3 / asyncio
# precise     ：只补全真正用到的标准库，体积更小
#               注意：precise 必须把 wheel 下载到 .kkpack/wheels 扫一遍 import
#               （runtime 模式"构建期不下载 wheel"在这里不成立），见「依赖处理的三种模式」
include_mode = "full"

[version]
# 写进 exe 的 PE 版本资源（菜单：属性 → 详细信息）
# 安全软件能读到的"这东西是谁做的"只有这一栏，建议至少填 company
company = "某某科技有限公司"
product = "某某工具"       # 省略 = exe 名
version = "1.2.0"               # 最多 4 段数字

[sign]
# 填了证书就在打包最后一步自动签名；留空 = 跳过
# certificate 三种写法：pfx 文件路径 / 证书主题名（CN=…）/ 40 位指纹
certificate = ""
timestamp_url = "http://timestamp.digicert.com"
```

配置文件里写了 kkpack 不认识的段或键，构建时会明确提示被忽略，不会出现"改了没生效"。

---

## requirements.txt 是唯一的依赖来源

依赖 **只写在 `requirements.txt` 里**，不要在配置文件里重复声明一遍 —— 避免两处版本号不一致这种最难查的问题。

```txt
requests==2.31.0
numpy==2.0.2
```

规则：

- **版本不强制锁定**，三种写法都收：

  | 写法 | 构建期行为 | 建议 |
  |---|---|---|
  | `pyserial` | 取 **当时的最新版** | 允许。构建日志会给出该补的锁定行 |
  | `pyserial>=3.4` | 取满足约束的最新版 | 允许。约束在构建期有效 |
  | `pyserial==3.5` | 就用这一版 | **推荐** —— 每次构建结果完全一致 |

- 三种写法最终都会被 **解析成一个具体版本** 并冻结进产物：`dist/*.dist/requirements.txt`
  与运行期清单 `REQUIREMENTS` 里都是 `name==version`。所以 **目标机装到的永远是同一份依赖**，
  差别只在"下一次构建会不会得到另一个版本"。
- **推荐锁定行由构建日志直接给出**。没写版本、写了范围的包会打印成这样：

  ```text
  没写版本，按最新解析：pyserial==3.5
  推荐锁定（写回 requirements.txt 即可让每次构建结果一致）：pyserial==3.5  pika==1.3.2
  ```

  把那串锁定行粘回 `requirements.txt`，锁定就完成了。
- 打包时会把它 **展开成完整依赖树**（包括 `urllib3` / `certifi` 这类间接依赖），
  展开后的清单随 exe 分发。你在开发机看到的依赖，就是目标机实际跑的那一份。
- 不支持 `-r other.txt`、`git+...`、VCS 与本地路径依赖。

---

## 依赖处理的三种模式

| 模式 | 产物体积 | 目标机首次启动 | 适用场景 |
|---|---|---|---|
| `runtime` | 最小 | 需要联网安装依赖 | 默认，绝大多数情况 |
| `offline` | 中（多了 `offline/` 目录） | 不联网，从随包 wheel 安装 | 内网 / 客户机不能联网 |
| `all` | 最大 | 不联网，依赖已在 exe 里 | 极端自包含需求（编译很慢，实测不划算） |

> **构建机至少要联网一次。** 三种模式的差别只在 **目标机**：打包时构建机必须先展开
> 依赖树（`urllib3` / `certifi` 这类间接依赖也要一起锁死），这一步要访问索引源。
> `runtime` 模式下这一步 **只取元数据、不下载 wheel** —— 解析结果缓存在 `.kkpack/` 下，
> `--clean` 也不会删。目标机首次运行时的下载是另一回事，别把这两次混起来看。
>
> **三种情况例外，会真把 wheel 取到本地**（构建日志里会写明是哪一条）：
>
> | 配置 | 为什么非下不可 |
> |---|---|
> | `[stdlib] include_mode = "precise"` | precise 要扫 wheel 里的 import，才知道该补哪些标准库模块 |
> | `mode = "offline"` | wheel 要随程序一起分发出去 |
> | `mode = "all"` | wheel 要解压出来编译进 exe |
>
> 所以用户的 `PySide6 + pyqtgraph + scipy` 这种工程，改成 `precise` 后
> `.kkpack/wheels/` 里会一次性出现 200+ MB 的 wheel —— 那不是"runtime 模式失效了"，
> 而是 precise 的代价。想省掉这次下载就改回 `full`（代价是 exe 里带着整个标准库）。

**精细控制**：默认全部依赖走"运行时安装"，但可以用 `[bundle] include / exclude` 单独指定：

```toml
[bundle]
mode = "runtime"
include = ["pillow"]      # pillow 编译进 exe，其余运行时安装
exclude = ["heavy-tool"]  # heavy-tool 一定不进 exe
```

优先级：`exclude > include > mode`。include 里写 `"*"` 等价于 `mode = "all"`。

### `runtime` 模式为什么也会下载 wheel：`precise` 的代价

> 「`runtime` 模式构建期不下载 wheel」有一个前提：**不需要读 wheel 里的内容**。
> `precise` 恰好要读。

`full`（默认）是把整个标准库塞进 exe，所以 **不需要知道你的代码用了哪些标准库模块**，
只看依赖元数据就够，一个 wheel 都不用下载。

`precise` 要算出"只补真正用到的那些"，就必须知道 **每个第三方包自己 import 了什么** ——
这些信息只存在于 wheel 的源码里。于是 kkpack 只能先把 wheel 取到 `.kkpack/wheels/`，
再逐个解压、AST 扫描（实现见 `backends.ast_stdlib_modules`）。

实测典型工程 `PySide6 + pyqtgraph + scipy`（Python 3.9 / win_amd64），
改成 `include_mode = "precise"` 之后 `.kkpack/wheels/` 里会一次性出现约 **267 MB**：

| wheel | 大小 | 它 import 的标准库（实测片段） |
|---|---|---|
| `PySide6_Addons` | 123.0 MB | `asyncio`、`contextvars`、`concurrent` |
| `PySide6_Essentials` | 78.9 MB | `logging`、`argparse`、`ast` |
| `scipy` | 46.2 MB | `itertools`、`warnings`、`math` |
| `numpy` | 15.9 MB | `subprocess`、`zipfile`、`operator` |
| `pyqtgraph` / `shiboken6` / `PySide6` | 1.9 / 1.1 / 0.5 MB | `weakref`、`marshal`、`base64` |

这不是"`runtime` 失效了"，也不是重复下载 —— 而是 `precise` 换取更小 exe 的 **必要成本**。

怎么区分正常与异常：

- **正常**：日志里会有一行
  `必须把 wheel 取到本地：标准库 precise 模式必须扫 wheel 里的 import，才知道该补哪些标准库模块`。
- **不正常**：看到 `pip download` 的输出，却 **没有** 上面这一行 —— 那才是真的出了问题。
- wheel 下过一次就留在 `.kkpack/wheels/`，`--clean` 也不会删，重复构建不会重下；
  但每次构建都会重新扫一遍（扫的是本地文件，很快，瓶颈只在第一次下载）。

想省掉这次下载，只有把 `include_mode` 改回 `full`（代价是 exe 里带着整个标准库，
体积和编译时间都上去）。**"exe 更小"与"构建期不下 wheel"目前只能二选一。**

---

## GUI 程序（没有黑窗口）

```bash
kkpack main.py --windowed
```

或在配置里 `[tool] console = false`。这会转成 Nuitka 的 `--windows-disable-console` /
PyInstaller 的 `--noconsole`。

关掉黑窗口后 `print` 都不再可见，所以 kkpack 在 **依赖安装这一段时间** 用 tkinter 弹一个进度条窗口：

- 阶段 1：正在下载依赖（显示已下载 MB 数与包进度）
- 阶段 2：正在安装依赖（逐个包）
- 装完自动关闭，接着启动你的程序

> **这个进度条和有没有黑窗口无关。** `console = true`（默认）时 **一样会弹** ——
> 黑窗口里是逐行的文字输出，进度条窗口是图形反馈，两者同时存在、互不冲突。
> 有黑窗口又不想弹窗，就把 `[runtime] progress` 设成 `false`。

配套选项：

| 位置 | 说明 |
|---|---|
| `[runtime] progress = true/false` | 是否弹进度条（默认 true，**与 `console` 无关**） |
| 环境变量 `KK_PROGRESS=0` | 单次运行临时关掉（`0` / `no` / `off` / `false` 都认） |
| tkinter 不可用 | 自动退回控制台输出，不影响安装 |

**什么时候不弹**：依赖已经装好的时候 —— `_deps/` 里存着安装记录，整段直接跳过，
既不弹窗也不联网。也就是说 **只有"确实有包要装"的那一次运行才会弹**，
之后每次启动都是安静的。依赖清单为空（`requirements.txt` 没内容）同样不会弹。

> "弹框到底有没有生效"最快的确认办法：`[tool] console = true` 打成黑窗口版再跑一次首次安装。
> 这时既能看见 tkinter 进度条，也能看见它前面的 `[kkpack] 需要安装 N 个包…`；
> 如果看到的是 `[kkpack] 依赖已就绪：N 个包已安装，跳过网络请求`，
> 说明依赖早就装好了，本来就不该弹框。
> 版本对照：**Windows x64 + Python 3.9 + Nuitka 2.7.13** 实测，`console = true`
> 的 PE 子系统为 Console(3) 时进度条照常显示（窗口类 `TkTopLevel`）。

### 打包 Qt / PySide6 程序

PySide6、PyQt5/6、pyqtgraph 这类 Qt 应用不需要额外参数，`kkpack main.py` 直接可用。
下面三件事 kkpack 已经替你处理好了，每一条都是真机踩出来的：

1. **`--nofollow-import-to` 的模块名不能带斜杠**。PySide6 官方 wheel 的 `top_level.txt` 里
   写的是路径（`PySide6/Qt3DCore`），原样传给 Nuitka 会直接
   `FATAL: ... not directory path`。kkpack 会统一规范成点号（`PySide6.Qt3DCore`）。
2. **不能把 built-in / frozen 模块当成 `--include-module`**。`_abc` / `_winapi` / `zipimport`
   这类由解释器本体提供、没有文件可 include；旧版 Nuitka（< 2.7）收到会在解析 include
   列表时内部崩溃（`os.path.abspath(None)`）。kkpack 按 `_imp.is_builtin / is_frozen`
   先把它们摘掉。
3. **产物根目录要带 `python3.dll`**。`cp39-abi3` 这类轮子（PySide6 / shiboken6）的扩展
   链接的是稳定 ABI 转发层 `python3.dll`，**不是** `python39.dll`；目标机上没有 Python，
   缺它就报 `ImportError: DLL load failed while importing Shiboken: 找不到指定的模块`。
   kkpack 会从构建机把它一并带进产物。

实测项目：**PySide6 6.7.2 + pyqtgraph 0.13.7 + numpy 2.0.2 + pygame + pyserial + pika**，
打包后运行 exe，Qt 窗口正常显示、事件循环正常退出。

> Qt 的插件（`platforms/qwindows.dll` 等）随 wheel 一起装在 `_deps/` 里，
> 所以 **别把 `_deps/` 只当缓存随手清**（删了下次启动会重新联网装一遍）。

### 进度条文案可配置

`[progress]` 段可以按项目改语言；占位符写错会自动退回默认文案，不会让安装崩掉：

```toml
[progress]
title = "Downloading components"
downloading = "Downloading {done}/{total}..."
installing = "Installing..."
format = "{mb:.1f} MB downloaded ({done}/{total})"
installed = "Installed {name}"
```

可用键：`title` / `prepare` / `downloading` / `installing` / `format` / `percent` /
`installed` / `package` / `cancel` / `cancelling` / `cancelled` / `failed_title` /
`failed_body` / `sources_tried` / `missing_files` / `offline_hint` / `enter_to_exit`。
占位符对应关系：

| 键 | 占位符 |
|---|---|
| `format` | `{mb}` 已下载 MB、`{done}`、`{total}` |
| `percent` | `{value}` 百分比、`{done}`、`{total}` |
| `installed` | `{name}` wheel 文件名 |
| `package` | `{name}`、`{version}` |

### 依赖安装中途能取消

进度窗口右下角有一个 **取消** 按钮，它和窗口右上角的 **X** 是同一件事：
立刻停掉下载、结束进程。

- 已经装好的包 **保持有效**（记录在 `_deps/<py版本-平台>/_installed.json` 里），
  下次启动只补没装完的那些，不会从头再来。
- 中断的那一刻正在解压的包不会留下记录，下次启动会重新解压它 ——
  解压本身可重复，不会留下坏状态。
- 退出码是 **130**（128 + SIGINT），和控制台里按 Ctrl-C 的约定一致。
- 没有进度窗口时（`[progress] progress = false` 或 `KK_PROGRESS=0`），
  在控制台按 **Ctrl-C** 走的是同一条路。

> **为什么取消是"直接结束进程"，而不是慢慢等它停下？** 取消的那一刻下载线程多半正卡在
> socket 的 `recv` 上（下大 wheel 时是常态），而 `concurrent.futures` 注册了 atexit，
> 解释器正常退出时会去 join 这些线程 —— 实测能一直拖到下载超时（几十秒），
> 那就不叫"关闭"了。kkpack 的做法是：先给工作线程 1 秒自己收尾，超时就直接退出。

---

## 产物名与图标

```toml
[tool]
exe_name = "MyApp"          # 产物（exe）名，不用写 .exe
icon = "assets/app.ico"     # 只支持 .ico，相对路径按项目根目录解析
```

等价命令行：`kkpack main.py --exe-name MyApp --icon assets/app.ico`。

### 产物名

默认取入口文件名（`main.py` → `main.exe`）。可以随便起，**含连字符、空格、点、
中文都能用**：`My-App`、`app.v2`、`我的工具` 都没问题。

会被拒绝的只有 Windows 本身不允许的：`\ / : * ? " < > |`、保留设备名
（`CON` / `PRN` / `AUX` / `NUL` / `COM1`-`COM9` / `LPT1`-`LPT9`）、首尾是点号的名字。
这些在构建一开始就明确报错，不会等编译器抛一堆看不懂的日志出来。

> **为什么含 `-` 的名字要特殊处理？** Nuitka 在 **目录形式**（`onefile = false`）下会
> 忽略 `--output-filename`，产物名只能取自入口文件名，而这个文件名同时会被解析成模块名，
> `My-App` 会直接解析失败。kkpack 的做法是：编译期用安全模块名，构建成功后再把
> 目录里那个 `<模块名>.exe` 改名为你要的名字。所以产物名完全不受模块命名规则限制，
> 你只需要在构建日志里看到一行"产物已按 exe_name 改名"就知道生效了。

改名是 **四种组合统一** 的（Nuitka / PyInstaller × 单文件 / 目录）：

| 形态 | 编译后改名 |
|---|---|
| 单文件 | `dist/<模块名>.exe` → `dist/<exe_name>.exe` |
| 目录形式 | `dist/<模块名>.dist/<模块名>.exe` → `dist/<模块名>.dist/<exe_name>.exe` |

**只改 exe 的名字，产物目录名不变。** 目录名保持后端的模块名（`<模块名>.dist`），
因为那是后端自己覆盖、自己维护的目录，改它只会多出一份"上一轮残留"要处理；
而目录名对使用者没有意义——拷给别人的是目录里的那个 exe。

### 图标

必须是 `.ico`（Windows 可执行文件的图标格式）。给 png 之类的文件会在构建开始时
报错并提示转换，不会等到编译结束才失败。图标会被编译进 PE 资源区。

**首次运行的下载进度窗口也会用这个图标。** tkinter 的窗口图标只能从一个 `.ico` 文件
加载（它拿不到 exe 自己的 PE 资源），所以构建时会 **再复制一份图标到 exe 同级**：

```text
dist/main/
├── main.exe          # 图标已编进 PE 资源
└── app.ico           # 同一份图标，供进度窗口使用
```

单文件（`onefile = true`）也一样会多出这个 `.ico` —— 这是让进度窗口不顶着 Tk 默认
羽毛图标的唯一办法（onefile 的临时解压目录会被运行期主动排除，打进 exe 反而找不到）。

不配置就用系统默认图标，进度窗口也不设图标，不影响其它功能。

---

## 版本信息与代码签名

这两件事只为一个目的：**让 Windows 和安全软件知道"这个 exe 是谁做的"**。
系统里能读到这些信息的只有两处 ——「属性 → 详细信息」里的版本资源，和数字签名。
两处都空着的未签名 exe，在安全软件的评分模型里就是个高分可疑文件（见下一节）。

### `[version]`：写进 exe 的 PE 版本资源

```toml
[version]
company = "某某科技有限公司"              # CompanyName
product = "某某工具"                # ProductName，省略 = exe 名
description = "用于xx用途"     # FileDescription，省略 = exe 名
version = "1.2.0"                        # FileVersion / ProductVersion
copyright = "版权所有 (C) 2026 某某科技有限公司"
trademark = ""
```

不写也能构建，兜底值保证这一栏 **不会是空的**：产品名和描述退回 exe 名，版本退回 `0.0.0`。
唯一不编造的是公司名 —— 在别人的程序里塞一个不存在的公司名，比这一栏空着危险得多。
所以完全没配时，构建日志里会有一条提示让你补 `company`。

规则与实现：

- `version` 最多 4 段数字（`1.2` / `1.2.0` / `1.2.0.4`），可以带 `v` 前缀。
  Windows 的版本资源本来就是 4 个 16 位整数，所以 `1.2.3-beta` 只能写进去 `1.2.3` ——
  丢掉的后缀会 **单独提示出来**，不静默截断。
- 两个后端写法不同、结果一致：Nuitka 逐项传 `--windows-company-name` 这类参数；
  PyInstaller 走一个自动生成的 `VSVersionInfo` 文件（`.kkpack/version_info.txt`，
  纯 ASCII、中文按 `\uXXXX` 转义，免得不同版本的 PyInstaller 在文件编码上打架）。
- 只在 Windows 上生效 —— 其它平台本来就没有这一栏。

### `[sign]`：构建末尾自动签名

```toml
[sign]
certificate = "C:/certs/app.pfx"           # 留空 = 不签名（默认）
password = ""                              # 留空则读环境变量 KK_SIGN_PASSWORD
timestamp_url = "http://timestamp.digicert.com"
signtool = ""                              # 留空自动找 PATH 和 Windows SDK
```

`certificate` 填了，打包的 **最后一步** 就会自动签名；留空就整段跳过、不碰签名。
三种写法对应 signtool 的三个参数：

| 写法 | 传给 signtool | 什么时候用 |
|---|---|---|
| `C:/certs/app.pfx` | `/f` + `/p` | 有证书文件（相对路径按项目根目录解析） |
| `CN=某某科技有限公司` | `/n` | 证书已装进「个人」证书存储（硬件令牌、云签名客户端都在那） |
| 40 位十六进制指纹 | `/sha1` | 存储里同名证书有多张，需要精确指定 |

几个刻意的设计：

- **签名在改名之后。** 产物名是编译完后改出来的（见上一节），签名必须落在最终产物上。
- **失败会让构建失败。** 静默发出一份"以为已经签好"的 exe 才是最坏的结果，
  所以签不上会明确报错并说清原因。临时要跳过就设 `KK_SIGN=0`，或把 `certificate` 留空。
- **密码为空也会显式传 `/p ""`。** signtool 缺 `/p` 会弹一个 GUI 密码输入窗口，
  在无人值守的构建里就是永久挂起；真挂住了还有超时兜底会报错退出。
- **签名后自动校签**（`signtool verify /pa`）。自签名证书或证书链没装全时校签会报错，
  但这不影响签名本身有效 —— 所以校签不过只提示、不判失败。
- **时间戳别忘了。** 不签时间戳的话，证书一过期，之前签过的所有版本签名会同时失效。
- 需要 `signtool.exe`，它随「Windows SDK」的 Signing Tools 组件安装
  （几十 MB，不必装整个 SDK 的编译器）。找不到时会提示装哪个、或把路径写进 `signtool`。
  云签名（Azure Trusted Signing 等）一般也提供 signtool 兼容的调用方式。

---

## 杀软误报怎么办

exe 被 Defender / 360 / 火绒拦下甚至直接删掉，绝大多数情况不是程序有问题，
而是 **它的形态和恶意软件太像**。按下面的顺序从便宜到贵地处理。

先分清是哪种：**被隔离/被删**（提示 `Trojan:Win32/xxx!ml` 这类，看
「Windows 安全中心 → 保护历史记录」）是杀软误报；**弹窗问是否允许联网** 是防火墙在问，
两者对策完全不同。检测名带 `!ml` 结尾说明是机器学习打的分、不是特征库命中 ——
这种最好治。

### 1. 改打包配置（零成本，今天就能做）

kkpack 的默认值是 **通用** 的，不是 **最不容易被误杀** 的。按收益从高到低：

| 措施 | 为什么 | 代价 |
|---|---|---|
| `onefile = false` | 单文件每次运行都要把自己解压到 `%TEMP%` 再执行 —— 这正是杀软眼里的"释放器"行为 | 分发变成目录 |
| `mode = "offline"` / `all` | `runtime` 模式会在 **首次运行时** 联网下载、解压、再 import，这条"下载即执行"链是行为监控最敏感的 | 产物变大 |
| 填 `[version] company` | 没有公司名的未签名 exe 只能靠文件内容猜 | 一行配置 |
| 填 `[version] version` | `0.0.0` 比一个正经版本号更像没人维护的野程序 | 一行配置 |
| 换 `backend` | Nuitka 的 stub 与 PyInstaller 的 bootloader 被打包器连坐拉黑时，换一个往往直接绕开 | 重编一次 |

`console` / `progress` 这些和误报无关，别在这上面花时间。

### 2. 误报申诉（免费，几小时到几天）

- 微软：<https://www.microsoft.com/en-us/wdsi/filesubmission>，选 software developer →
  false positive。会回复检测名和处理结论。
- 国内：360 安全开放平台、腾讯电脑管家、火绒各有独立入口，要分别提。

> **别把样本传 VirusTotal 求"清白"。** VT 会把样本共享给各家引擎，常见效果是让更多杀软
> 更快把这一版拉黑。它适合查已有哈希，不适合提交自己的新版本。

### 3. 代码签名（治本）

见上一节。预期要说清：**签名不是立刻免死金牌** —— OV 证书要靠下载量攒
SmartScreen 信誉，EV 证书才是即时信任；个人开发者申请 OV/EV 通常需要企业资质。
但签了之后，"未签名"这个最强的负分项就消失了。

### 4. 换个分发形态

用 Inno Setup / NSIS 把 `xxx.dist/` 打成安装包（装到 `Program Files`、写卸载项）。
行为画像立刻从"从临时目录跑起来的裸 exe"变成"正常安装的软件"，
顺带解决单文件往 `%TEMP%` 释放文件的观感问题。

---

## 目标机上的行为

```
xxx.dist/
├── xxx.exe
├── requirements.txt          # 冻结的依赖清单
├── _deps/                    # 首次运行时生成（按 py版本-平台 分目录）
│   └── py39-win32-amd64/
└── offline/                  # 仅 offline 模式有：随包 wheel
```

- 依赖装进 `_deps/<py版本-平台>/`，**不会污染目标机的 Python 环境**（也没有 Python 环境）。
- C 扩展自带的 DLL 目录（如 `numpy.libs`）会自动注册，multiprocessing 子进程也会继承。
- 全部依赖都下不到时，会打印（或弹窗）**缺哪些包 + sha256 + 直链 + 离线目录**，
  把 wheel 放进 `offline/` 目录重启即可，不会悄悄崩掉。

---

## 多进程（multiprocessing）

`spawn` / `Pool` 的写法可以直接打包，不用改代码，也不用自己调 `freeze_support()`：

```python
import multiprocessing as mp

def _worker(n):                       # 定义在入口文件里也没问题
    import numpy as np
    return int(np.arange(1, n + 1).sum())

def main():
    ctx = mp.get_context("spawn")     # Windows 上没有 fork，用 spawn 最稳
    with ctx.Pool(processes=2) as pool:
        print(pool.map(_worker, [4, 5]))

if __name__ == "__main__":
    main()
```

冻结后，spawn 的子进程会以 `exe --multiprocessing-fork` 的形式重启 exe。
kkpack 在运行期初始化时识别出它，自动完成三件事：

1. **跳过依赖安装** —— 沿用父进程算好的 `_deps` 目录（否则 onefile 每次解压目录不同，
   子进程会装到又一个新地方）。
2. **重新注册 DLL 目录** —— `os.add_dll_directory` 是进程级的、不会继承，numpy 这类
   带 `.libs` 的 C 扩展必须在子进程里再注册一次。
3. **把入口文件里的函数与类补回 `__main__`** —— Windows 冻结后 multiprocessing
   不会重建 `__main__`（见 multiprocessing.spawn 里的 WINEXE 分支），少了这一步子进程
   会报 `Can't get attribute '_worker' on <module '__main__' (built-in)>`。

唯一约定：worker 函数要放在 **能被 import 的模块里或入口文件顶层**，不要藏在
`if __name__ == "__main__":` 内部 —— pickle 按引用序列化时会取不到它。

---

### 运行期环境变量（排障/临时覆盖用）

| 变量 | 作用 |
|---|---|
| `KK_INDEXES` | 逗号分隔的镜像源，覆盖打包时配置的源列表 |
| `KK_OFFLINE` | 指向离线 wheel 目录 |
| `KK_PROGRESS` | 设为 `0` 关闭进度条窗口 |
| `KK_TRACE` | 设为 `1` 把每次 bootstrap 写进 `_kk_trace.log`，用来查子进程有没有重跑 |

---

## 常见问题

**Q：必须要 `requirements.txt` 吗？**
是，且不能为空。它是依赖的唯一来源，没有它 kkpack 不知道目标机该装什么。
（完全没有第三方依赖的项目，暂不支持——后续版本会放开。）

**Q：`runtime` 模式打包时会下载 wheel 吗？**
默认不会（`.kkpack/wheels/` 是空的）——`[bundle] include` 里的包例外，那些必须
下载、要解压出来编译进 exe。解析完整依赖树靠 pip 的 `--dry-run` / `--report`：
只取每个包的 `.metadata`（KB 级），直链与 sha256 由 pip 的 report 给出，
再按文件名从你配的镜像页补一份镜像直链。
两个前提 kkpack 自己兜住了：构建环境的 pip 低于 22.2 时，另外装一份新的到
`.kkpack/pipenv` 供解析使用（**不升级构建环境本身的 pip** —— 那份还要用来跑你的
构建，就地升级在 Windows 上会把环境 pip 装成半个）；源不支持 PEP 658 元数据时
（阿里云、清华目前都不支持）自动用官方 PyPI 兜底解析，**下载仍然优先走你配的镜像**。
两件事都可以在 `[index]` 里关掉：`upgrade_pip = false` / `pypi_fallback = false`，
关掉后 pip 太老或源不支持就只能退回"把 wheel 下载到本地"。

**Q：改成 `include_mode = "precise"` 之后，`runtime` 模式为什么又下载了 200+ MB wheel？**
因为 `precise` 要拆开每个 wheel 看它 import 了哪些标准库模块，才能算出该往 exe 里补哪些，
所以必须先把 wheel 取到 `.kkpack/wheels/`。这是 `precise` 的固有代价，不是 `runtime`
失效，也不是重复下载。详见 [`runtime` 模式为什么也会下载 wheel](#runtime-模式为什么也会下载-wheelprecise-的代价)。
想省掉这次下载就改回 `include_mode = "full"`。

**Q：首次运行时进度窗口关不掉 / 想中断下载？**
现在 **可以**：窗口右下角的 **取消** 按钮和右上角的 **X** 是同一个动作 ——
立即停掉下载并结束进程（退出码 130）。已经装好的包保持有效，下次启动只补没装完的
那些；没有进度窗口时（`progress = false` 或 `KK_PROGRESS=0`）在控制台按 **Ctrl-C**，
走的是同一条路。详见[依赖安装中途能取消](#依赖安装中途能取消)。

**Q：Nuitka 报"没有 C 编译器"？**
装 MinGW64 或 MSVC，或者直接用 `kkpack main.py --backend pyinstaller`（PyInstaller 不需要编译器，但没有 Nuitka 快、体积也更大）。

**Q：`--only-binary` 相关的下载失败？**
kkpack 用 `pip download --only-binary=:all:` 取 wheel，只有源码包（sdist）的库会失败。
解决办法：换一个提供了 wheel 的版本，或改用 `--backend pyinstaller` + `mode = "all"` / `include`。

**Q：编译很久 / 内存爆了？**
`--jobs 1` 或 `2`；`include` 里少放包；`mode = "runtime"`（默认）比 `all` 快得多。LTO 是默认关闭的，别打开。

**Q：项目放在中文目录下，Nuitka 编译明明成功了却报 `UnicodeDecodeError`？**
ccache 是原生程序，日志按系统 ANSI 代码页写（中文 Windows 就是 GBK），而 Nuitka
（2.7 以前）用 UTF-8 读这个日志，于是在收尾统计时崩：
`UnicodeDecodeError: 'utf-8' codec can't decode byte 0xd7 ...`。
kkpack 会在构建路径含非 ASCII 字符时自动加 `--disable-ccache`（Nuitka 只在启用 ccache
时才写这个日志，关掉即可），并在日志里打一行提示 —— 中文路径下只是失去 ccache 的
重复编译加速，功能与产物不受影响。

**Q：目标平台和构建平台必须一致吗？**
目前 yes —— wheel 是按构建时的平台/Python 版本下载的（默认 `win_amd64`）。在目标平台上打包，或用对应平台的机器各打一份。

**Q：动态 import 的模块能被打包吗？**
kkpack 会用 AST 扫描你项目里所有 `.py`，识别 `importlib.import_module("xxx")` 并登记，
同时对包用 `--include-package` 收整棵子树。实在扫不到的，构建时会提示"动态导入未解析"，
在 `[bundle] include` 里显式声明即可。

**Q：用了 multiprocessing，报 `Can't get attribute 'xxx' on <module '__main__'>`？**
把 worker 函数从 `if __name__ == "__main__":` 里挪到文件顶层（或独立模块）。
pickle 只能记录函数的引用位置，藏在守卫块里的对象子进程还原不出来。

**Q：`exe_name` 能用中文或连字符吗？**
能，`My-App`、`我的工具` 都可以。产物名只受 Windows 文件名字符限制，不受 Python
模块命名规则限制——kkpack 会用安全模块名编译，结束时再把产物改成你要的名字。
（详见 [产物名与图标](#产物名与图标)。）

**Q：改了 `exe_name` 之后，之前打包的产物还在？**
同名重复构建会 **直接覆盖**（旧产物被整个换掉），所以反复构建不会越堆越多。
但如果把 `exe_name` 换成了新名字，旧名字那份产物不会自动清理，建议先删掉
`output_dir`（默认 `dist/`），否则新旧两份同时存在、容易拷错。

---

## 已知限制

- `requirements.txt` 必须存在且非空
- 打包机需要能访问 PyPI 或镜像源（`runtime` 模式也一样）；元数据解析需要一个支持
  PEP 658 的源，默认用官方 PyPI 兜底（`[index] pypi_fallback = false` 可关）
- 跨平台构建暂不支持（需目标平台 + 目标 Python 版本）
- 不支持 VCS / 本地路径依赖（`git+https://...`）
- `--windowed` 下运行期无控制台输出，排查请配合 `KK_TRACE=1`

## 环境要求

| 项 | 要求 |
|---|---|
| 操作系统 | **仅 Windows**（依赖 wheel 按 `win_amd64` 取，其它平台不做承诺） |
| Python | 3.8 及以上 |
| 打包后端 | Nuitka 需要 MSVC 或 MinGW64；PyInstaller 不需要 C 编译器 |
| kkpack 自身 | 零第三方依赖 |

不确定环境行不行就先跑 `kkpack doctor`，上面这几项它一次体检完。

---

## License

MIT。许可证原文随包分发：sdist 根目录的 `LICENSE`，以及 wheel 里的
`.dist-info/licenses/LICENSE`。

## 关于作者

微信公众号：Python卡皮巴拉

🌟【Python卡皮巴拉】—— 你的Python修炼秘籍，代码界的“神兽”驾到！🌟
