跳转至

打包发布到 PyPI + typer:让全世界一行 pip install 用上你的工具

各位有没有过这样的瞬间——

水哥自己在本地写了一个小工具,比如「批量重命名截图文件」、「统计微信导出的聊天记录」、「自动给图片加水印」。脚本一两百行,跑起来挺顺手,自己天天用。然后某天一个朋友看见了,说:「这玩意儿挺好啊,能给我一份吗?」

水哥于是把 tool.py 用微信发过去。朋友收到之后,第一句话是——「这怎么用?」

「装一下 pillowhttpx。」

「好,装好了。」

「再装一下 pyyaml。」

「装好了。」

「跑一下 python tool.py。」

「报错了,说找不到 requests。」

「哦对,还要 requests。」

来回折腾五分钟,朋友放弃了。

各位看,这就是 Python 工具的「最后一公里」问题——代码自己能跑,可是给别人用就费劲。Node 圈一行 npm install -g xxx,Rust 圈一行 cargo install xxx,Go 圈一行 go install xxx,到了 Python 这边,难道就要「先装这个再装那个」吗?

当然不是。Python 也有自己的「全球应用商店」,叫 PyPI(Python Package Index)。把工具按照规范打包发上去,别人就能用一行 pip install my-tool 装上,再也不用各位手把手教了。

这一章咱们把整个流程从零跑通。学完之后,各位应该能:

  1. 知道 PyPI 是什么、它跟 pip 是啥关系
  2. 把自己的项目改造成「可发布」的标准结构
  3. uv build 一条命令把项目打成 wheelsdist
  4. 先在 TestPyPI 演练一遍,再正式发布到 PyPI
  5. [project.scripts] 把 Python 函数变成系统命令
  6. typer 写出比 argparse 优雅 10 倍的命令行工具
  7. 把整套发布流程接进 GitHub Actions,打 tag 自动发版

后半段我们会用 typer 写一个 todo-cli 的命令行小工具,再把它发到 PyPI,让全世界都能 pip install todo-cli 装上。这个小项目把前后两块知识串起来,跑一遍就全懂了。

走起。

PyPI 是个啥

各位平时一行 pip install requests,包是从哪儿下载的?答案是 PyPI。

PyPI(Python Package Index)是 Python 官方维护的全球公共包仓库,地址 https://pypi.org。截至 2026 年,上面挂着 60 多万个 Python 包,每个月被下载几百亿次。所有 Python 圈知名的包——requestsnumpyflaskdjangopandasfastapi——都住在 PyPI 上。

pip install xxx 这条命令的本质是——「从 PyPI 下载叫 xxx 的包,装到当前 Python 环境」。

PyPI 跟 GitHub 是啥关系?两者各管一摊:

  • GitHub 是放源代码的地方,给开发者看
  • PyPI 是放打好包的地方,给用户用 pip

一个项目通常两头都有——源码托管在 GitHub,发布版本上传到 PyPI。各位常用的 requestsflask,源码都在 GitHub,包都在 PyPI,两边版本号对得上。

PyPI 还有一个「兄弟站点」叫 TestPyPI,地址 https://test.pypi.org。它是 PyPI 的演练场——发版本之前先发到 TestPyPI 试一下,确认没问题再发正式 PyPI。两个网站各自独立,账号、密码、token 都不通用。

「为什么要有 TestPyPI?」因为 PyPI 正式版本一旦发布就不能修改、不能删除。各位发版本的时候手抖了,把 0.1.0 发上去之后发现版本号写错了或者打包漏文件,没法撤回,只能再发一个 0.1.1,那个错版本会永远挂在 PyPI 上当历史档案。所以发正式 PyPI 之前先在 TestPyPI 演练一遍,是发版本的好习惯。

接下来我们就一步步搭一个能发到 PyPI 的项目。

先把项目结构搭对

各位写 Python 项目的时候,最常见的目录长这样:

my-tool/
├── my_tool.py
└── README.md

或者稍微讲究一点的:

my-tool/
├── my_tool/
│   ├── __init__.py
│   └── main.py
└── README.md

这种结构本地跑没问题,可一旦要打包发布,就会暴露一个隐藏的坑——当前目录会被 Python 自动加进 sys.path。结果跑测试的时候,导入的是「项目根目录的源码」而不是「装到环境里的包」,问题就被遮住了。

社区现在推荐的写法叫 src layout,长这样:

my-tool/
├── pyproject.toml
├── README.md
├── LICENSE
├── src/
│   └── my_tool/
│       ├── __init__.py
│       └── main.py
└── tests/
    └── test_main.py

注意中间多了一层 src/ 目录。这个 src/ 不是装饰,它的作用是——强行让源码不在项目根目录。这样你就必须把包装到环境里才能 import,避免了上面说的坑。

「这样写麻烦吗?」一点都不麻烦。配合 uvpyproject.toml,工具会自动识别 src/ 布局,啥都不用配。

各位现在用 uv 起一个新项目,看看默认结构:

uv init --package my-tool
cd my-tool
ls -R

输出大概长这样:

.
├── README.md
├── pyproject.toml
└── src
    └── my_tool
        └── __init__.py

uv init --package 这一行就帮各位把 src layout 的骨架搭好了。注意这里加了 --package——不加这个参数,uv init 默认搭的是「应用」结构(不打包),加上才是「库/工具」结构(要打包发布)。

打开 pyproject.toml 看看 uv 给生成了什么:

[project]
name = "my-tool"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.10"
dependencies = []

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project.scripts]
my-tool = "my_tool:main"

各位看,骨架已经齐了——[project] 段写元数据、[build-system] 段写打包后端、[project.scripts] 段写命令行入口。下一步咱们逐个字段把它填充完整。

pyproject.toml 必填字段拆解

要发到 PyPI,[project] 段里有几个字段是绕不过的——名字、版本、描述、Python 版本要求、依赖。下面挨个讲。

name:包的全网唯一名字

name 就是发到 PyPI 之后,别人用 pip install xxx 时填的那个 xxx它在 PyPI 全网唯一,跟域名一样,先到先得。各位起名前最好先到 https://pypi.org/project/xxx/ 看一下有没有重名。

命名规则——只允许字母、数字、-_.,不区分大小写,不能以数字开头。社区习惯用小写加连字符,比如 my-tooltodo-clihttp-prompt

注意一个细节:包名(name)和导入名(import xxxxxx)不一定一样。比如 pip install scikit-learn,但 import sklearnpip install Pillow,但 import PIL。这是因为:

  • name 是 PyPI 上的项目名,可以带连字符
  • 导入名是 Python 模块名,必须是合法 Python 标识符,不能带连字符

水哥的建议是——为了不让用户搞晕,两个名字尽量保持一致。比如包名叫 my-tool,导入名就叫 my_tool(连字符变下划线,Python 圈通用习惯)。

version:版本号

version 是这个发布版本的版本号,写成字符串,比如 "0.1.0""1.2.3""2.0.0a1"

社区强烈推荐用 语义化版本(SemVer),三段数字 主.次.补丁

  • 主版本号:不向后兼容的大改动(删函数、改签名)
  • 次版本号:向后兼容的新功能
  • 补丁号:向后兼容的 bug 修复

新项目从 0.1.0 起步,到了 1.0 之前都算「不稳定」,API 可以随便改。1.0 之后再改 API 就要小心了。

PyPI 还有一条铁规——同一个版本号只能上传一次。各位发了 0.1.0 之后想撤回重新发,对不起,办不到。必须改成 0.1.1 或者 0.1.0.post1 重新发。

description:一句话描述

description 是一句话简介,会显示在 PyPI 页面顶部,搜索结果里也会带。写得稍微像样一点,别 "Add your description here" 这种默认值就发上去——上线之后看着尴尬。

description = "两点水的小工具,批量给图片加水印"

readme:长描述

readme 是项目的长描述,发到 PyPI 之后会显示在项目页面正文。指向一个 Markdown 文件即可:

readme = "README.md"

uv init 已经帮各位生成了一个空的 README.md,把内容补上。建议至少包含——一段简介、安装命令、最小用法示例。

authors / maintainers:作者

authors 写作者列表,每个作者是一个对象,至少写名字,邮箱可选:

authors = [
    { name = "两点水", email = "liangdianshui@example.com" },
]

requires-python:Python 版本要求

requires-python 限定这个包支持哪些 Python 版本:

requires-python = ">=3.10"

各位不要写 >=3.0,那样老到不能再老的 Python 都会尝试装,结果一堆兼容问题。2026 年的合理选择是 >=3.10>=3.11,3.9 已经在 EOL 边缘。

dependencies:依赖列表

dependencies 是这个包运行时需要的其他包:

dependencies = [
    "httpx>=0.27",
    "typer>=0.12",
]

每条遵守 PEP 508 语法——包名 + 版本约束。常用的版本约束符——

  • >=0.27:大于等于 0.27 即可,最常用
  • ~=0.27:兼容版本,>=0.27, <0.28
  • ==0.27.1:固定版本,几乎不用
  • >=0.27, <1.0:限定范围

「写不写版本号?」强烈建议写最低版本,比如 httpx>=0.27。这样别人装的时候,至少能保证 httpx 不会因为太老而缺函数。上限不要随便写,写死会增加用户的依赖冲突概率。

完整的最小可发布 pyproject.toml 大概长这样:

[project]
name = "my-tool"
version = "0.1.0"
description = "两点水的小工具"
readme = "README.md"
requires-python = ">=3.10"
authors = [
    { name = "两点水", email = "liangdianshui@example.com" },
]
dependencies = [
    "httpx>=0.27",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

下一节讲 [build-system] 是个啥。

build-backend:谁来负责打包

各位刚才的 pyproject.toml 末尾有这么一段:

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

这两行在干啥?要解释清楚,得先理清 Python 打包的角色分工。

Python 打包过程里有两个角色——前端后端

  • 前端(frontend) 是用户敲的命令,比如 pipuvbuild
  • 后端(backend) 是真正干活的库,把源码变成 wheelsdist

PEP 517 这个标准把两者拆开了,前端只负责调用,后端可以自由替换。[build-system] 段就是告诉前端:「我用的是哪个后端」。

社区目前主流的后端有三个——

  • hatchling:来自 Hatch 项目,配置简单、扩展性强,新项目首选
  • setuptools:上古时代留下来的老牌后端,几乎所有老项目都在用
  • poetry-core:Poetry 项目自带的后端,跟 Poetry 工具一起用

各位还可能在野外见到 flit-corepdm-backendmaturin(Rust 写的扩展用),都是同一个生态位的不同选择。

为啥推荐 hatchling?三个原因——

  1. 配置简单uv init --package 默认就是它,啥都不用改
  2. 现代化。原生支持 src layout、PEP 621 元数据,没有历史包袱
  3. 生态有官方背书hatch 是 PyPA(Python Packaging Authority)旗下的项目

setuptools 不是更普及吗?」普及没错,但是配置又多又乱,新项目没必要自找麻烦。除非你需要 C 扩展,或者要兼容 10 年前的老代码,否则 hatchling 闭着眼睛选就行。

各位 pyproject.toml 这一段保持默认就好:

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

requires 列出「构建这个项目时需要装哪些包」。这里的依赖跟 [project].dependencies 不一样——后者是「跑这个项目要的包」,前者是「打包这个项目要的包」。

下一节咱们试着把项目打包出来。

本地构建:uv build 一条命令出炉

各位写完代码之后,怎么把项目打成可发布的格式?一条命令——

uv build

跑一下,输出大概是:

Building source distribution...
Building wheel from source distribution...
Successfully built dist/my_tool-0.1.0.tar.gz
Successfully built dist/my_tool-0.1.0-py3-none-any.whl

打开 dist/ 目录看看:

dist/
├── my_tool-0.1.0-py3-none-any.whl
└── my_tool-0.1.0.tar.gz

两个文件分别是啥?

  • .whl 文件(wheel):预编译好的二进制包。用户 pip install 时会优先选这个,因为不用编译,秒装
  • .tar.gz 文件(sdist,source distribution):源码包。包含整个项目源码,给那些环境特殊、需要从源码编译的用户用

文件名里有个规则——包名-版本-Python 版本-ABI-平台

  • py3:兼容所有 Python 3
  • none:不依赖特定的 ABI
  • any:跨平台

要是你的包包含 C 扩展,whl 名字就会变成 my_tool-0.1.0-cp311-cp311-linux_x86_64.whl,限定 CPython 3.11、Linux x86_64 上能用。每个平台都要单独打一个 whl

纯 Python 项目就一个 py3-none-any.whl,全平台通吃,最省事。

uv 没装怎么办?」用官方的 build 工具也行:

pip install build
python -m build

效果一模一样。各位看自己习惯,本章后面统一用 uv

构建成功之后,一定要先在本地装一下试试

uv pip install dist/my_tool-0.1.0-py3-none-any.whl

或者:

pip install dist/my_tool-0.1.0-py3-none-any.whl

装完之后跑跑你的工具,确认能用。这一步是发版本前的最后一道防线,发到 PyPI 之前如果发现问题,还能改;发上去就晚了。

注册 TestPyPI 账号

包打好了,下一步——发上去。先发 TestPyPI 演练,再发正式 PyPI。

TestPyPI 注册流程——

  1. 打开 https://test.pypi.org/account/register/
  2. 用邮箱注册账号,验证邮箱
  3. 登录之后,进 Account settings
  4. 开启 两步验证(2FA),TestPyPI 现在强制要求 2FA。可以用 Authy、Google Authenticator、1Password 这类 TOTP App
  5. API tokens 页面,创建一个 token,scope 选 Entire account(第一次发包没办法限定到具体项目,因为项目还不存在)
  6. 把生成的 token 复制下来,只能看一次,关掉页面就没了

TestPyPI 的 token 长这样:

pypi-AgEIcHlwaS5vcmcCJ...(一长串 base64)

把这个 token 保存在密码管理器里。

正式 PyPI 的注册流程一样——https://pypi.org/account/register/,开 2FA,建 token,存好。注意 PyPI 跟 TestPyPI 是两个独立网站,账号、token 都是分开的。

「token 怎么用?」下一节就用上了。

上传到 TestPyPI 演练

TestPyPI 的发布命令——

uv publish --publish-url https://test.pypi.org/legacy/ \
    --token pypi-AgEIcHlwaS5vcmcCJ...

或者用环境变量更安全:

export UV_PUBLISH_TOKEN=pypi-AgEIcHlwaS5vcmcCJ...
uv publish --publish-url https://test.pypi.org/legacy/

跑成功之后,输出大概是:

Uploading my_tool-0.1.0-py3-none-any.whl
Uploading my_tool-0.1.0.tar.gz
Published https://test.pypi.org/project/my-tool/0.1.0/

打开链接看看,自己的小工具已经躺在 TestPyPI 上了,标题、描述、版本号、README、依赖列表全都有。

下一步——用 TestPyPI 装一下,确认能用

pip install --index-url https://test.pypi.org/simple/ \
    --extra-index-url https://pypi.org/simple/ \
    my-tool

注意这里两个 index-url 参数——

  • --index-url https://test.pypi.org/simple/:从 TestPyPI 找你刚发的包
  • --extra-index-url https://pypi.org/simple/:从正式 PyPI 找依赖(因为你的依赖比如 httpx 是发在正式 PyPI 上的)

不加第二个参数,TestPyPI 上的依赖会装不全(TestPyPI 只有你刚上传的,没有 httpx)。这是新手最常踩的坑,记一下。

装完跑你的工具,能跑通,恭喜各位,发包流程已经掌握了。

正式上传到 PyPI

TestPyPI 演练成功之后,正式发 PyPI 就一条命令——

export UV_PUBLISH_TOKEN=pypi-正式的-token-...
uv publish

uv publish 默认就是发到 https://upload.pypi.org/legacy/,也就是正式 PyPI。

发完之后,打开 https://pypi.org/project/my-tool/,自己的项目就在 PyPI 上了。任何人现在都能:

pip install my-tool

把你的工具装到自己的环境里。水哥写到这里都觉得有点小激动——这意味着你的代码已经在全球公共仓库上了,谁都能用上。

「我发包之后想下架怎么办?」PyPI 提供了一个「yank」机制——版本号会保留,但默认不再被新装机会找到。注意只是「藏起来」不是「删除」,已经装上的用户还能继续用。完全删除一个版本是不允许的,避免破坏依赖链。

版本号管理:手动 vs 自动

各位发完 0.1.0,过了一段时间想发 0.1.1。怎么改版本号?

手动法——打开 pyproject.toml,把 version = "0.1.0" 改成 version = "0.1.1",然后重新 uv build + uv publish。简单直接。

但是手动法有个问题——pyproject.toml 和 git tag 容易对不上。比如 pyproject.toml0.1.1,git tag 是 v0.2.0,到底哪个才算正确?这种漂移积累几个版本就乱套。

自动法——用 hatch-vcs,从 git tag 自动算版本号。改一下 pyproject.toml

[project]
name = "my-tool"
dynamic = ["version"]
description = "两点水的小工具"
# ...

[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"

[tool.hatch.version]
source = "vcs"

注意三处变化——

  1. version = "0.1.0" 改成 dynamic = ["version"],告诉 hatchling「版本号不是写死的,动态算」
  2. [build-system].requires 加上 hatch-vcs,引入算版本号的插件
  3. 新增 [tool.hatch.version] 段,告诉 hatch-vcs「从 git 读」

之后流程变成——

git tag v0.1.1
git push --tags
uv build
uv publish

uv build 时,hatch-vcs 会读 git 最新的 tag v0.1.1,自动得出版本号 0.1.1,包名就是 my_tool-0.1.1-py3-none-any.whl

要是你打 tag 之后又改了代码,hatch-vcs 还会自动加上一个 dev 后缀,版本号变成 0.1.2.dev3+gabc1234,明显告诉你「这是个开发中的版本」。

水哥的建议是——新项目用自动法。一开始用手动法没问题,但是到了第三、第四个版本,开发者多起来的时候,自动法能省掉很多对帐的功夫。

README 和 LICENSE:最低限度的「正经感」

各位发到 PyPI 的项目,必须有 README.md 和 LICENSE 文件,这是基本的行业礼仪。

README.md

README.md 在 PyPI 项目页面会被渲染成正文,是用户第一眼看到的东西。建议至少包含——

# my-tool

两点水的小工具,批量给图片加水印。

## 安装

```bash
pip install my-tool

用法

my-tool ./photos --watermark "© 两点水"

License

MIT

简介、安装、用法、License,四段够用了。讲究一点的项目还会加上「特性」、「示例」、「FAQ」、「贡献指南」。

### LICENSE

License 是法律文件,告诉用户「我允许你怎么用我的代码」。各位写开源项目,不写 License 等于「保留所有权利」,反而是最严的——别人不能拿来用,因为没拿到许可。

常用的 License 三选一——

- **MIT**:最宽松,「你随便用,别告我,写一句版权声明就行」
- **Apache 2.0**:跟 MIT 类似,加了一条「专利授权」条款,对企业更友好
- **GPL v3**:传染性 License,「你用了我的代码,你的代码也得开源」

社区惯例——工具/库类项目用 MIT 或 Apache 2.0,应用类项目自己拿主意。水哥的小工具一律 MIT,省事。

`pyproject.toml` 里也声明一下:

```toml
[project]
license = { file = "LICENSE" }

或者写 SPDX 标识(PEP 639,2024 年正式落地):

[project]
license = "MIT"

后者更现代,但是有些老工具还不认,看你的容忍度。

CI 自动发包:打 tag 就发版

每次发包都手动 uv build + uv publish,跑两三次就嫌烦。最理想的状态是——打个 git tag,CI 自动构建并发到 PyPI

GitHub Actions 加一段 workflow 就能做到。在项目里建一个文件 .github/workflows/release.yml

name: Release

on:
  push:
    tags:
      - "v*"

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: astral-sh/setup-uv@v3

      - run: uv build

      - uses: pypa/gh-action-pypi-publish@release/v1

这段配置干了什么——

  1. 触发条件push 一个以 v 开头的 tag(比如 v0.1.1)就跑
  2. permissions: id-token: write:给 workflow 申请 OpenID Connect 权限,下面的「可信发布」要用
  3. 构建:用 uv build 打包
  4. 发布:用 pypa/gh-action-pypi-publish 上传到 PyPI

最神奇的是——这套流程不需要写 PyPI token。靠的是「可信发布(Trusted Publishers)」机制,2023 年 PyPI 推出,2024 年成为推荐做法。

可信发布的原理是——PyPI 直接信任 GitHub Actions 的 OIDC 身份。你在 PyPI 项目设置里关联「这个 GitHub 仓库的这个 workflow 文件」之后,CI 跑起来 PyPI 就认识,不用 token 就能上传。

配置步骤——

  1. 登录 PyPI,进项目页面 → SettingsPublishing
  2. 添加一个「Trusted Publisher」,填 Owner(你的 GitHub 用户名/组织)、Repository name(仓库名)、Workflow namerelease.yml)、Environment(可选)
  3. 推一个 tag 试试

打 tag 的命令——

git tag v0.1.1
git push origin v0.1.1

GitHub Actions 跑起来,几分钟之后包就在 PyPI 上了。

「比起 token 有啥好处?」三点——

  1. 不用管 token 过期。token 默认一年过期,到点就要重建
  2. 不用怕 token 泄露。OIDC 凭证只在 workflow 跑的那一瞬间存在,没法拷出来
  3. 配置在 PyPI 一边。换 token 不用改 secrets,权限管理更清晰

水哥强烈推荐 2026 年的新项目一上来就用可信发布,比 token 干净太多。

到这里,发包到 PyPI 这部分讲完了。下一节我们换个话题——typer,写 CLI 工具的利器。

CLI 的痛:argparse 太啰嗦

各位写过命令行工具吗?Python 标准库自带一个 argparse,能用,但是写起来啰嗦得要命。看一段:

import argparse

parser = argparse.ArgumentParser(description="给图片加水印")
parser.add_argument("input", type=str, help="输入图片路径")
parser.add_argument("--watermark", type=str, default="© 两点水", help="水印文本")
parser.add_argument("--opacity", type=float, default=0.5, help="不透明度")
parser.add_argument("--verbose", action="store_true", help="显示详细信息")
args = parser.parse_args()

print(f"输入:{args.input}")
print(f"水印:{args.watermark}")
print(f"不透明度:{args.opacity}")
print(f"详细:{args.verbose}")

这段代码 9 行,但实际有用的逻辑只有 4 个变量。为啥这么啰嗦?因为 argparse 是 2009 年写的,那时候 Python 还没有类型注解。所有参数都得手动 add_argument 一遍,类型、默认值、帮助文本各填一遍,函数签名跟参数声明分两处,改起来还容易漏。

更糟的是子命令——比如 git commitgit push 这种「主命令 + 子命令」结构,argparse 写起来嵌套一堆 subparsers,看一眼就头疼。

到了 2026 年,各位有没有更好的选择?

有,叫 typer。它是 FastAPI 作者 Tiangolo 写的,思路跟 FastAPI 一脉相承——用类型注解直接当参数声明。同样的功能,typer 写出来是这样:

import typer

app = typer.Typer()


@app.command()
def watermark(
    input: str,
    watermark: str = "© 两点水",
    opacity: float = 0.5,
    verbose: bool = False,
):
    print(f"输入:{input}")
    print(f"水印:{watermark}")
    print(f"不透明度:{opacity}")
    print(f"详细:{verbose}")


if __name__ == "__main__":
    app()

各位看看——参数直接写在函数签名里,类型注解就是参数类型,默认值就是命令行默认值,帮助文本能从 docstring 自动提取。逻辑跟 argparse 等价,行数少了一半,可读性高了 10 倍。

接下来咱们把 typer 从最简单的开始一步步讲。

第一个 typer

各位先装一下 typer

uv add typer

或者:

pip install typer

写一个最简单的脚本 hello.py

import typer


def hello(name: str):
    print(f"Hi {name}")


if __name__ == "__main__":
    typer.run(hello)

跑一下:

python hello.py 两点水

输出:

Hi 两点水

各位再试试不带参数:

python hello.py

输出:

Usage: hello.py [OPTIONS] NAME
Try 'hello.py --help' for help.

Error: Missing argument 'NAME'.

typer 自动帮你做了——

  • 解析命令行参数
  • 类型转换(namestr,从命令行字符串直接拿)
  • 缺参数报错
  • 提示有 --help 命令

试试 --help

python hello.py --help

输出:

Usage: hello.py [OPTIONS] NAME

Arguments:
  NAME  [required]

Options:
  --help  Show this message and exit.

帮助文本是 typer 自动生成的。各位有没有发现——你压根没写 parser.add_argument、没写 --help、没写 Usage,全是 typer 自动整出来的。这就是「类型注解即文档」的威力。

typer.run(hello) 这一行做的事情是「把 hello 函数变成一个命令行入口」。它适合只有一个命令的小工具。如果各位的工具有多个命令(比如 addlistdone),就需要下一节讲的 Typer app。

多命令 app

各位写过 git 没?git commitgit pushgit pull,一个主命令带一堆子命令。这种结构在 typer 里叫 多命令 app

import typer

app = typer.Typer()


@app.command()
def add(item: str):
    """添加一项任务。"""
    print(f"添加:{item}")


@app.command()
def list():
    """列出所有任务。"""
    print("列出全部任务...")


@app.command()
def done(index: int):
    """标记任务完成。"""
    print(f"完成第 {index} 项")


if __name__ == "__main__":
    app()

各位看,三个步骤——

  1. app = typer.Typer():创建一个 app 实例
  2. @app.command() 装饰每个命令对应的函数
  3. app() 启动

跑一下:

python todo.py add "学 typer"
python todo.py list
python todo.py done 1

输出:

添加:学 typer
列出全部任务...
完成第 1 项

每个 @app.command() 装饰的函数都会变成一个子命令,函数名就是子命令名。用户输入 add 时跑 add 函数,输入 list 时跑 list 函数。

各位试试 --help

python todo.py --help

输出:

Usage: todo.py [OPTIONS] COMMAND [ARGS]...

Options:
  --install-completion  Install completion for the current shell.
  --show-completion     Show completion for the current shell.
  --help                Show this message and exit.

Commands:
  add   添加一项任务。
  done  标记任务完成。
  list  列出所有任务。

注意每个命令旁边的描述——它来自每个函数的 docstring。typer 直接读 docstring 生成命令帮助,docstring 写得清楚一点,CLI 文档就完整了。

子命令的帮助也有:

python todo.py add --help

输出:

Usage: todo.py add [OPTIONS] ITEM

  添加一项任务。

Arguments:
  ITEM  [required]

add 子命令的描述、参数列表全自动生成。

类型注解 = CLI 参数

typer 最神奇的地方在于——函数参数的类型注解,就是 CLI 参数的类型。各位写什么类型,CLI 自动转什么类型,不用写一行类型转换代码。

支持的类型——

import typer
from pathlib import Path
from enum import Enum


class Color(str, Enum):
    red = "red"
    green = "green"
    blue = "blue"


def cmd(
    text: str,
    count: int,
    ratio: float,
    enabled: bool,
    file: Path,
    color: Color,
):
    print(f"text={text!r}")
    print(f"count={count} (类型 {type(count).__name__})")
    print(f"ratio={ratio} (类型 {type(ratio).__name__})")
    print(f"enabled={enabled}")
    print(f"file={file} (类型 {type(file).__name__})")
    print(f"color={color}")

跑这个命令——

python cmd.py "hi" 10 1.5 ./README.md red

输出:

text='hi'
count=10 (类型 int)
ratio=1.5 (类型 float)
enabled=False
file=README.md (类型 PosixPath)
color=Color.red

注意几个细节——

  • int 类型,命令行的 "10" 自动变成整数
  • float 类型,命令行的 "1.5" 自动变成浮点数
  • Path 类型,自动包装成 pathlib.Path 对象
  • Enum 类型,限定值只能是 red/green/blue,输入别的值直接报错

要是各位输错类型试试:

python cmd.py "hi" abc 1.5 ./README.md red

typer 直接拒绝:

Usage: cmd.py [OPTIONS] TEXT COUNT RATIO FILE COLOR
Try 'cmd.py --help' for help.

Error: Invalid value for 'COUNT': 'abc' is not a valid integer.

「类型即验证」——你写 inttyper 就只接受能转成 int 的值,转不了的连函数都进不去。这跟用 argparse 还得自己 try except 一对比,简直舒服。

Optional 参数:默认值的魔法

各位回到刚才的例子,参数 text: str 是「必填」还是「可选」?是必填,因为没写默认值。

要是想让它变成可选,加一个默认值即可:

def hello(name: str = "World"):
    print(f"Hi {name}")

这时候 typer 会把它从「位置参数」变成「选项参数」,命令行用法变成:

python hello.py            # 输出 Hi World
python hello.py --name 两点水  # 输出 Hi 两点水

注意——

  • 没有默认值的参数 = 位置参数(Argument),必填,直接跟在命令后面
  • 有默认值的参数 = 选项参数(Option),可选,要带 --name 前缀

这跟 Python 函数的「位置参数 vs 关键字参数」逻辑一致。各位写 Python 函数有没有默认值,决定了 CLI 是位置参数还是选项参数。

短选项也很容易加,用 typer.Option

import typer


def hello(
    name: str = typer.Option("World", "--name", "-n", help="谁的名字"),
):
    print(f"Hi {name}")


if __name__ == "__main__":
    typer.run(hello)

这时候——

python hello.py -n 两点水
python hello.py --name 两点水

两种写法等价。typer.Option 第一个参数是默认值,后面是各种命令行别名,最后 help 是帮助文本。

必选参数 vs 可选参数:一句话区分

各位老在「位置参数」和「选项参数」之间纠结,水哥给一个一句话原则——

  • 「这个值非给不可」→ 位置参数。比如 cp 源 目标,没源、没目标,命令没法跑
  • 「这个值可以省略,省略时有个默认行为」→ 选项参数。比如 ls --all,不加也能跑

写到 typer 函数签名里——

def watermark(
    input: Path,                 # 位置参数:必填
    output: Path,                # 位置参数:必填
    text: str = "© 两点水",       # 选项参数:可选,默认水印
    opacity: float = 0.5,        # 选项参数:可选,默认 0.5
    verbose: bool = False,       # 选项参数:可选,默认 False
):
    ...

CLI 用法:

my-tool input.jpg output.jpg
my-tool input.jpg output.jpg --text "两点水版权所有" --opacity 0.8 --verbose

各位写 CLI 工具的时候,位置参数一般 1-3 个,多了用户记不住顺序。3 个以上的参数全用选项参数,写出来反而清楚。

交互式输入:prompt 和 confirm

各位写 CLI 工具的时候,常常有这种场景——「这个参数用户没传,我想问一下他」。比如删除文件之前问「确定吗?」、初始化项目时问「项目名叫啥?」。

老办法是用 input()typer 提供了更好用的两件武器——typer.prompttyper.confirm

先看 prompt,它问用户一个问题然后拿到答案:

import typer


def init():
    name = typer.prompt("项目名叫啥")
    author = typer.prompt("作者是谁", default="两点水")
    print(f"创建项目 {name},作者 {author}")


if __name__ == "__main__":
    typer.run(init)

跑起来——

项目名叫啥: my-tool
作者是谁 [两点水]:
创建项目 my-tool,作者 两点水

default 参数给一个默认值,用户直接回车就用默认值。比 input() 漂亮一截。

confirm 是「问 yes/no」:

import typer


def delete(path: str):
    sure = typer.confirm(f"确定要删除 {path} 吗")
    if not sure:
        print("取消")
        raise typer.Abort()
    print(f"已删除 {path}")


if __name__ == "__main__":
    typer.run(delete)

跑起来——

$ python del.py /tmp/foo
确定要删除 /tmp/foo 吗 [y/N]: y
已删除 /tmp/foo

confirm 默认是「不」(用户直接回车 = no),加 default=True 改成默认「是」。

typer.Abort() 是另一个特殊异常——「用户主动放弃」,CLI 会用一个统一的退出码(默认 1)退出,比 typer.Exit() 语义更清楚。

错误退出:typer.Exit 和 typer.Abort

各位写 CLI 工具,有些情况下要让命令以「失败」退出——文件不存在、网络挂了、用户输错值。Linux 圈的惯例是「退出码 0 = 成功,非 0 = 失败」,shell 脚本会根据这个判断要不要继续。

typer 提供了两种退出方式——

import typer


def deploy(env: str):
    if env not in ("dev", "staging", "prod"):
        print(f"不认识的环境:{env}")
        raise typer.Exit(code=2)
    print(f"开始部署到 {env}")

typer.Exit(code=N) 直接让 CLI 退出,退出码是 N。约定俗成——

  • 0:成功
  • 1:通用失败
  • 2:用户输错参数

各位也别瞎用 sys.exit(N)——它绕过了 typer 的退出钩子,写日志、清理资源都来不及。统一用 typer.Exit

另一个是 typer.Abort(),「用户中止」的语义。比如上面 confirm 用户答了 no,就 raise typer.Abort()。它本质上等价于 typer.Exit(code=1),但语义更清楚。

彩色输出:echo 和 secho

各位看 git status 输出有没有发现——modified 文件是红的、staged 文件是绿的、提示文本是黄的。命令行工具加点颜色,可读性立刻提升。

typer 内置了简单的彩色输出,叫 typer.echotyper.secho

import typer


def status():
    typer.echo("普通文本,跟 print 一样")
    typer.secho("成功!", fg=typer.colors.GREEN)
    typer.secho("警告!", fg=typer.colors.YELLOW)
    typer.secho("错误!", fg=typer.colors.RED, bold=True)


if __name__ == "__main__":
    typer.run(status)

跑起来在支持彩色的终端里——「成功!」是绿色、「警告!」是黄色、「错误!」是红色加粗。在不支持彩色的环境(比如重定向到文件),typer 自动去掉色码,输出还是干净的。

可选颜色——BLACKREDGREENYELLOWBLUEMAGENTACYANWHITE,外加 bold=Trueunderline=True 这些样式。

「为啥不用 print?」print 没办法自动判断终端是否支持彩色。重定向到文件的时候,色码会变成乱七八糟的 \x1b[32m 之类的字符,污染输出。用 typer.secho 自动处理这个细节。

要更花哨的输出(表格、进度条、Markdown 渲染),typer 可以无缝接 rich——pip install "typer[all]" 之后,它默认就用 rich 当后端。

子命令嵌套:app.add_typer

各位写大型 CLI 工具,命令多了之后想分组管理——比如 git remote addgit remote rmgit stash pushgit stash pop 这种「子命令的子命令」。

typer 支持子 app 嵌套:

import typer

app = typer.Typer()
remote_app = typer.Typer()
stash_app = typer.Typer()

app.add_typer(remote_app, name="remote", help="管理远程仓库")
app.add_typer(stash_app, name="stash", help="管理暂存区")


@remote_app.command("add")
def remote_add(name: str, url: str):
    print(f"添加远程:{name} -> {url}")


@remote_app.command("rm")
def remote_rm(name: str):
    print(f"删除远程:{name}")


@stash_app.command("push")
def stash_push():
    print("暂存当前修改")


@stash_app.command("pop")
def stash_pop():
    print("恢复最近一次暂存")


if __name__ == "__main__":
    app()

跑起来——

python git.py remote add origin git@github.com:walter/python.git
python git.py remote rm origin
python git.py stash push
python git.py stash pop

每个子 app 又可以再嵌套子 app,理论上无限层。但是各位别真嵌套三层以上,用户记不住。两层够用。

打包成系统命令:[project.scripts]

各位现在的 typer 工具,跑起来是 python todo.py add ...,每次都要加 python,不像 git 那样直接敲。怎么变成全局命令?

答案是 pyproject.toml 里的 [project.scripts] 段:

[project.scripts]
todo-cli = "my_tool.cli:app"

格式是 命令名 = "包路径:函数名"。这一行的意思是——

  • 装这个包之后,命令行多一个叫 todo-cli 的命令
  • todo-cli 时,等于跑 my_tool/cli.py 里的 app 函数

typer.Typer() 实例本身就是可调用的,直接当 app 用。

各位重装一下包:

uv pip install -e .

然后试试:

todo-cli --help

直接出帮助。再试:

todo-cli add "学 typer"
todo-cli list

跟之前 python todo.py add ... 一模一样,但是命令短了一截,体验立刻像样起来。

uv pip install -e . 是啥意思?」-e 是 editable 模式,把包以「软链接」的方式装进去——你改源码,命令立刻生效,不用重装。开发期间一定加 -e,省去无数次重装。

到这里,发包 + CLI 的所有零件都齐了。下一节用一个完整的小项目把所有东西串起来。

实战:todo-cli 从零到发布

各位跟着水哥写一个完整的 todo-cli 工具,最后发到 PyPI(演练版本发到 TestPyPI)。这个工具支持三个命令——

  • todo-cli add "买菜":添加一项 todo
  • todo-cli list:列出所有 todo
  • todo-cli done 1:标记第 1 项完成

数据存在用户家目录下的 ~/.todo-cli.json,最简单的本地存储。

第一步:起项目骨架

uv init --package todo-cli
cd todo-cli
uv add typer

uv 自动生成 src/todo_cli/__init__.py。我们再加一个文件 src/todo_cli/cli.py

第二步:实现核心逻辑

import json
from pathlib import Path

import typer

app = typer.Typer(help="两点水的小型 todo 工具")

DATA_FILE = Path.home() / ".todo-cli.json"


def load_todos() -> list[dict]:
    """读 todo 列表,文件不存在返回空 list。"""
    if not DATA_FILE.exists():
        return []
    return json.loads(DATA_FILE.read_text(encoding="utf-8"))


def save_todos(todos: list[dict]) -> None:
    """把 todo 列表写回文件。"""
    DATA_FILE.write_text(
        json.dumps(todos, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )


@app.command()
def add(item: str):
    """添加一项 todo。"""
    todos = load_todos()
    todos.append({"item": item, "done": False})
    save_todos(todos)
    print(f"已添加:{item}")


@app.command(name="list")
def list_todos():
    """列出所有 todo。"""
    todos = load_todos()
    if not todos:
        print("还没有 todo,先 add 一个吧")
        return
    for i, t in enumerate(todos, start=1):
        mark = "[x]" if t["done"] else "[ ]"
        print(f"{i}. {mark} {t['item']}")


@app.command()
def done(index: int):
    """把第 index 项 todo 标记为完成。"""
    todos = load_todos()
    if index < 1 or index > len(todos):
        print(f"序号要在 1 到 {len(todos)} 之间")
        raise typer.Exit(code=1)
    todos[index - 1]["done"] = True
    save_todos(todos)
    print(f"已完成:{todos[index - 1]['item']}")


if __name__ == "__main__":
    app()

注意几个细节——

  • list 是 Python 内置函数,不能直接当函数名,所以函数叫 list_todos,但是用 @app.command(name="list") 把它注册成 list 命令
  • Path.home() / ".todo-cli.json"pathlib,比 os.path.join 优雅多了(参考 python18 那一章)
  • typer.Exit(code=1) 抛一个特殊异常,让 CLI 退出时返回非零退出码,方便脚本判断成败
  • 文件用 encoding="utf-8",windows 上中文不挂

第三步:配置 pyproject.toml

完整的 pyproject.toml

[project]
name = "todo-cli"
version = "0.1.0"
description = "两点水写的最小 todo 命令行工具"
readme = "README.md"
requires-python = ">=3.10"
authors = [
    { name = "两点水", email = "liangdianshui@example.com" },
]
license = { file = "LICENSE" }
dependencies = [
    "typer>=0.12",
]

[project.scripts]
todo-cli = "todo_cli.cli:app"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

注意 [project.scripts] 那一行——发包之后,用户 pip install todo-cli,会自动多出一个叫 todo-cli 的命令。

第四步:本地装一下试试

uv pip install -e .
todo-cli add "写完 python27"
todo-cli add "去吃饭"
todo-cli list
todo-cli done 1
todo-cli list

输出大概是——

已添加:写完 python27
已添加:去吃饭
1. [ ] 写完 python27
2. [ ] 去吃饭
已完成:写完 python27
1. [x] 写完 python27
2. [ ] 去吃饭

打开 ~/.todo-cli.json 看看:

[
  {
    "item": "写完 python27",
    "done": true
  },
  {
    "item": "去吃饭",
    "done": false
  }
]

数据被持久化了,下次重启电脑,todo 还在。

第五步:补 README 和 LICENSE

README.md

# todo-cli

两点水写的最小 todo 命令行工具。

## 安装

```bash
pip install todo-cli

用法

todo-cli add "学 typer"
todo-cli list
todo-cli done 1

License

MIT

`LICENSE` 文件直接抄一份 MIT,把年份和名字改成自己的。

### 第六步:构建并发到 TestPyPI

```bash
uv build
ls dist/

得到——

dist/
├── todo_cli-0.1.0-py3-none-any.whl
└── todo_cli-0.1.0.tar.gz

发 TestPyPI 演练:

export UV_PUBLISH_TOKEN=pypi-...(TestPyPI token)
uv publish --publish-url https://test.pypi.org/legacy/

成功之后,找一台干净的电脑(或者新建一个虚拟环境)测一下:

uv venv .test-env
source .test-env/bin/activate
pip install --index-url https://test.pypi.org/simple/ \
    --extra-index-url https://pypi.org/simple/ \
    todo-cli
todo-cli --help

能跑就发正式 PyPI:

export UV_PUBLISH_TOKEN=pypi-...(正式 PyPI token)
uv publish

打开 https://pypi.org/project/todo-cli/,自己的小工具上线了。水哥写到这里又有点小激动——这就是「全世界一行 pip install todo-cli 就能用」的感觉。

「这个名字是 PyPI 上唯一的吗?」名字起得太常见,PyPI 上很可能已经被占用,请各位实际发包时换一个独特点的名字,比如 todo-cli-by-waltermy-todo-cli 之类。

到这里,整套流程一遍走通了——从写代码、改 pyproject.toml、本地装、本地测、TestPyPI 演练、正式 PyPI 发布。

小结

一章下来,咱们把「打包发布」和「写 CLI」两件事都拿下了。回顾一下要点——

关于 PyPI

  1. PyPI 是 Python 全球公共包仓库pip install 的源头
  2. TestPyPI 是演练场,发正式版本之前先在它上面试一遍,因为 PyPI 版本不可撤回
  3. 现代 Python 项目用 src layout + pyproject.toml,所有元数据集中在一个文件
  4. [project] 段最低必填:name、version、description、readme、requires-python、dependencies
  5. [build-system] 段写打包后端,新项目首选 hatchling
  6. uv build 一条命令打包,生成 wheel(二进制)和 sdist(源码)
  7. uv publish 一条命令上传,靠 token 或者可信发布
  8. 版本号建议自动化——用 hatch-vcs 从 git tag 自动算
  9. CI 自动发包推荐用可信发布,比 token 干净——打 tag 即发版

关于 typer

  1. typer 用类型注解直接当 CLI 参数,比 argparse 简洁 10 倍
  2. typer.run(func) 适合单命令工具Typer() + @app.command() 适合多命令工具
  3. 类型即验证——int 类型自动验证、Path 类型自动包装、Enum 类型自动限定值
  4. 没默认值是位置参数(必填),有默认值是选项参数(可选)
  5. [project.scripts] 把函数变成系统命令,装包之后命令全局可用
  6. docstring 自动变成命令帮助文本,写代码就是写文档

到这里,各位手里就握着一套「写工具 → 打包 → 发布 → 让全世界用上」的完整链条。下一次再想把自己的小工具分享给朋友,不用微信发 tool.py 了——直接说「pip install xxx」,潇洒。

各位接下来给自己一个小作业——把日常用的某个 Python 脚本(截图重命名也好、聊天记录统计也好),按照本章的流程改造一遍,发到 TestPyPI 上演练一次。哪怕没真发到正式 PyPI,单走完一遍流程,下次发起来就是肌肉记忆了。

走起。