Metadata-Version: 2.4
Name: py2winapp-cli
Version: 0.1.1
Summary: 一键把 Python Web 应用打包成 Windows 桌面程序
Author-email: Chandler <275737875@qq.com>
License: MIT
Keywords: desktop,exe,flask,packaging,pyinstaller,windows
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: MacOS :: MacOS X
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
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: Topic :: System :: Archiving :: Packaging
Requires-Python: >=3.9
Requires-Dist: jinja2>=3
Requires-Dist: pillow>=10
Requires-Dist: pydantic>=2
Requires-Dist: pystray>=0.19
Requires-Dist: rich>=13
Requires-Dist: tomli>=2; python_version < '3.11'
Requires-Dist: typer>=0.9
Provides-Extra: build
Requires-Dist: build; extra == 'build'
Requires-Dist: twine; extra == 'build'
Provides-Extra: dev
Requires-Dist: mypy; extra == 'dev'
Requires-Dist: pytest-cov; extra == 'dev'
Requires-Dist: pytest>=7; extra == 'dev'
Requires-Dist: ruff; extra == 'dev'
Requires-Dist: types-pillow; extra == 'dev'
Description-Content-Type: text/markdown

# py2winapp

> 一键把 Python Web 应用打包成 Windows 桌面程序。
> 双击 exe 即用，无需安装 Python。启动有闪屏、托盘常驻、单实例保护、自动选端口、自动开浏览器、自定义图标。

## 它解决什么问题？

你写了个 Flask/FastAPI 应用，想发给没有 Python 环境的用户。`py2winapp` 把你的 Web 应用连同 Python 解释器、依赖、启动器一起打进一个 exe。用户双击后：闪屏 → 后台起 Web 服务 → 自动开浏览器 → 托盘常驻。体验和原生桌面软件一样。

## 安装

```bash
pip install py2winapp
```

## 工作原理

```
双击 exe
  → 闪屏"正在启动…"
  → 启动器选空闲端口（5050-5200）
  → 调你的 run_web(host, port) 起本地 Web 服务
  → 自动打开浏览器访问 http://127.0.0.1:<端口>
  → 托盘常驻，关浏览器不退出，点托盘"退出"才退
```

本质是"本地 Web 服务 + 自动打开的浏览器 + 托盘常驻的启动器"，三者由 PyInstaller 打进一个 exe。`py2winapp` 不修改你的源码，仅通过配置注入。

## 快速开始（4 步出 exe）

### ① 写入口函数

```python
# myapp/web.py
from flask import Flask

def run_web(host: str, port: int, **kwargs) -> None:
    app = Flask(__name__)

    @app.route("/")
    def index():
        return "<h1>你好，桌面应用！</h1>"

    app.run(host=host, port=port, debug=False, use_reloader=False)
```

### ② 初始化项目

```bash
py2winapp init MyApp --entry-module myapp.web --with-icon
```

生成 `py2winapp.toml` + `app/`、`build/` 骨架 + 默认图标。

### ③ 本地调试（不打包，秒起）

```bash
py2winapp run
```

### ④ 打包成 exe（需 Windows）

```bash
py2winapp build
```

产物在 `dist/MyApp/MyApp.exe`，把整个 `dist/MyApp/` 文件夹发给用户。

## 命令一览

| 命令 | 说明 | 示例 |
|------|------|------|
| `init` | 初始化项目 | `py2winapp init MyApp --entry-module myapp.web --with-icon` |
| `icon` | 生成自定义图标 | `py2winapp icon --letter A --bg red --apply` |
| `run` | 开发调试（不打包） | `py2winapp run --no-splash --no-tray` |
| `build` | 打包成 exe | `py2winapp build --mode onefile` |
| `spec` | 仅生成 .spec（任意平台） | `py2winapp spec` |
| `clean` | 清理构建产物 | `py2winapp clean --what all` |
| `inspect` | 环境诊断 | `py2winapp inspect` |
| `version` | 打印版本 | `py2winapp version` |

**图标预设色**：`blue green red purple orange teal pink dark black white`，也支持 `#RRGGBB`。

## 入口函数要求

```python
def run_web(host: str, port: int, **kwargs) -> None
```

- **必须阻塞**（启动 Web 服务后不 return）。
- `host`、`port` 由启动器注入（选好的空闲端口），不要写死。
- `**kwargs` 向前兼容，别和 `host`/`port` 重名。

## 配置

全部参数在 `py2winapp.toml`（也支持 `pyproject.toml` 的 `[tool.py2winapp]`）。5 大段：`[app]` `[icon]` `[runtime]` `[build]` `[output]`。

### 全字段速查

| 段.字段 | 默认 | 说明 |
|---------|------|------|
| `app.name` | (必填) | 显示名（exe 名） |
| `app.slug` | (必填) | 内部标识（小写字母+数字+下划线，小写开头） |
| `app.version` | `0.1.0` | 版本号 |
| `app.entry.module` | (必填) | 入口模块，如 `myapp.web` |
| `app.entry.function` | `run_web` | 入口函数名 |
| `app.entry.extra_kwargs` | `{}` | 调用入口函数时额外传的参数（别含 host/port） |
| `app.data.user_data_dir` | `${APPDATA}/${app.slug}` | 用户数据目录（支持变量插值） |
| `app.data.chdir_to_user_data` | `true` | 启动时切到用户数据目录 |
| `app.data.include_packages` | `[]` | 强制打包的包（数据文件靠这个收集） |
| `app.data.hidden_imports` | `[]` | PyInstaller hiddenimports |
| `app.data.excludes` | `[]` | 排除的包（减小体积） |
| `icon.source` | `app/assets/icon.ico` | 图标路径 |
| `icon.auto_generate_on_missing` | `true` | 缺图标时自动生成 |
| `runtime.host` | `127.0.0.1` | 监听地址（`0.0.0.0` 允许局域网） |
| `runtime.port_start` / `port_end` | `5050` / `5200` | 端口扫描区间（end 必须 > start） |
| `runtime.port_wait_timeout` | `30.0` | 等服务就绪秒数 |
| `runtime.enable_splash` | `true` | 启动闪屏 |
| `runtime.splash_title` | `${app.name}` | 闪屏标题 |
| `runtime.splash_size` | `[320, 120]` | 闪屏宽高（两个正整数） |
| `runtime.enable_tray` | `true` | 托盘常驻 |
| `runtime.enable_single_instance` | `true` | 单实例保护 |
| `runtime.auto_open_browser` | `true` | 自动开浏览器 |
| `runtime.browser_detach` | `true` | 浏览器脱离父进程 |
| `build.mode` | `onedir` | `onedir`（快）或 `onefile`（单文件） |
| `build.console` | `false` | `true` 保留黑框（调试用） |
| `build.venv_dir` | `.venv-build` | 打包用 venv 目录 |
| `build.isolate_venv` | `true` | 每次重建 venv |
| `build.upx` | `false` | UPX 压缩（需装 UPX） |
| `build.codesign_identity` | `None` | 代码签名证书 |
| `build.dependencies.extra` | `[]` | 额外 pip install 的包 |
| `output.zip_artifact` | `false` | 打包后自动 zip |
| `output.zip_name` | `${app.slug}-${app.version}.zip` | zip 文件名 |

### 变量插值

配置中 `${...}` 会被替换。**小写点号**查应用字段：`${app.name}` `${app.slug}` `${app.version}`。**大写**查环境变量：`${APPDATA}`（Windows 为 `%APPDATA%`，非 Windows 回退 `~/.config`）、`${HOME}`、`${LOCALAPPDATA}`。仅 `user_data_dir`、`splash_title`、`zip_name` 三个字段支持插值。**改配置后必须重新 `build`**（插值在打包时完成，写进生成的 launcher.py）。

### 配置校验

加载时用 pydantic v2 校验，不通过则退出码 2。常见拒绝：`slug` 大写/含连字符、`port_end ≤ port_start`、`mode` 非 `onedir`/`onefile`、`splash_size` 不是两个正整数、缺必填字段。

## 适配已有项目

你的项目入口可能不是 `run_web`（比如是 typer CLI 或 FastAPI app 对象）。**不要改原代码**，新建一个适配模块即可。

### FastAPI 项目适配

假设你已有 `myapp/web/app.py` 里的 `app = FastAPI()`，新建 `myapp/desktop.py`：

```python
import uvicorn
from .web.app import app

def run_web(host: str, port: int, **kwargs) -> None:
    uvicorn.run(app, host=host, port=port, log_level="warning", access_log=False)
```

然后 `py2winapp init MyApp --entry-module myapp.desktop`。

**uvicorn hidden_imports**（FastAPI 项目必加，否则运行时 ModuleNotFoundError）：

```toml
[app.data]
hidden_imports = [
    "uvicorn.logging", "uvicorn.loops", "uvicorn.loops.auto",
    "uvicorn.protocols", "uvicorn.protocols.http", "uvicorn.protocols.http.auto",
    "uvicorn.lifespan", "uvicorn.lifespan.on",
]
```

### 数据文件（yaml/json）打包后找不到

PyInstaller 默认只打包 .py。数据文件靠 `include_packages` 触发 `collect_data_files` 自动收集：

```toml
[app.data]
include_packages = ["myapp"]   # 包内所有非 .py 文件会被收集
```

运行时用 `sys._MEIPASS` 定位资源（打包后资源解压到临时目录）：

```python
import sys
from pathlib import Path

def resource_path(relative: str) -> Path:
    """兼容开发和打包环境。relative 如 'data/registry.yaml'"""
    base = Path(sys._MEIPASS) / "myapp" if hasattr(sys, "_MEIPASS") else Path(__file__).parent
    return base / relative
```

### 依赖配置

你项目的运行时依赖要列进 `build.dependencies.extra`：

```toml
[build.dependencies]
extra = ["fastapi>=0.100", "uvicorn>=0.23", "jinja2>=3.1", "pyyaml>=6"]
```

## 常见问题

| 问题 | 解决 |
|------|------|
| Mac/Linux 上 `build` 报错 | PyInstaller 不能跨平台。用 `py2winapp spec` 生成 spec，到 Windows 上 `pyinstaller xxx.spec` |
| 双击 exe 闪退 | `py2winapp build --console` 重新打包看黑框报错；或看 `%APPDATA%/<slug>/launcher-error.log` |
| exe 太大 | `excludes` 排除 matplotlib/numpy/pandas/PyQt 等；开 `upx = true`；用 `onefile` |
| FastAPI 运行时报 ModuleNotFoundError | uvicorn 子模块需列入 `hidden_imports`（见上方） |
| 数据文件打包后找不到 | 用 `sys._MEIPASS` 定位资源，`include_packages` 要包含你的包 |
| 端口被占 | 改 `port_start`/`port_end` 到空闲区间 |
| 第二次双击 exe 没反应 | `enable_single_instance = true` 时正常行为，会打开已有实例的浏览器 |

## 退出码

| 码 | 含义 |
|----|------|
| 0 | 成功 |
| 1 | 通用失败 |
| 2 | 配置错误 |
| 3 | 环境不满足 |
| 4 | 构建失败 |
| 5 | 产物校验失败 |

## 技术栈

CLI: typer + rich ｜ 配置: pydantic v2 + TOML ｜ 模板: jinja2 ｜ 图标: Pillow ｜ 托盘: pystray ｜ 闪屏: tkinter ｜ 打包: PyInstaller

## License

MIT
