用 uv + Python 开发命令行工具
当使用 uv 写正规一点的 CLI 应用的时候,还是应该使用- uv init --package [package name]
复制代码 因为写一个命令行程序总是要安装的,想分享到 PYPI 也必须要打包。
我都不知道我第一次用 uv 的时候是怎么正确打包,并能使用 uv 安装我开发的程序。当时真是误打误撞的,那时候我根本不知道 pyproject.toml 应该怎么写才能正确打包,更加不知道写一个命令行程序的规范是什么。当时我连 Build Backend 都没有设置,纯粹是运气好。
目录结构
一个 Python 应用,不管是 CLI 工具,还是后端服务,只要是一个想分发给别人用,项目都要有一个规范的目录结构, flat-layout or src-layout。现在比较流行 src-layout,如果没有什么考量就使用 src-layout。
- src layout vs flat layout - Python Packaging User Guide
有固定的目录结构之后,一个包(文件夹)里面的__init__.py很重要,构建系统默认是只把有 __init__.py文件的文件夹才当作是一个包。当然 __init__.py 可以没有,没有 __init__.py 的包叫做 namespace package。要打包 namespace package 需要在 pyproject.toml里面给构建系统指出怎么找到你的包。
Python 项目打包的细节我不太清楚,可以看看对应的构建后端的文档,例如 hatch 的文档。
uv 的使用
uv init
uv init 初始化一个项目默认没有使用 --lib, uv 就会使用 --app,相当于- uv init --app example-app
复制代码- $ tree example-app
- example-app/
- ├── main.py
- ├── pyproject.toml
- └── README.md
复制代码 uv init 创建一个 Python 项目,自动建立好 pyproject.toml,README.md 等文件。在这里你可以随意创建 .py 文件,也可以手动自己把代码组织成 package。
运行对应的 py 文件,也称为脚本。uv 不仅仅是一个包管理器,还可以说是 Python 构建系统的前端,还是 Python 环境的管理器。
当你想写一个 Python 脚本做点事情,随便在一个文件夹运行 uv init 帮你创建好虚拟环境并管理依赖。运行脚本就直接使用 uv run script.py。
uv init --package
如果是写一个正经的应用,想分发一个包或者分发一个可执行的命令,推荐使用 uv init --package。当然也可以先用 uv init 起步,然后再自己手动创建包。这相当于 uv init --app --package。
uv 会使用 src-layout,把代码放在 src/ 的 Python 包下,uv 会在 pyproject.toml 中添加一个 [project.scripts] entrypoint。
我们使用 uv init --package 创建一个 example-pkg,我们在这里使用了 --package。- ~> uv init --package example-pkg
- Initialized project `example-pkg` at `/home/user/example-pkg`
- ~> tree example-pkg/
- example-pkg/
- ├── pyproject.toml
- ├── README.md
- └── src
- └── example_pkg
- └── __init__.py
- 2 directories, 3 files
- ~> cd example-pkg/
复制代码 --package 告诉 uv,我们希望用 Python 包来组织代码。因为代码放在 src/ 的文件夹里面,运行起来不太方便。uv 在 pyproject.toml 中帮我们声明了 [project.scripts]。- [project.scripts]
- example-pkg = "example_pkg:main"
复制代码 [project.scripts] 声明了我们这个项目会有哪些命令,当执行这个 example-pkg 命令的时候调用对应的函数。example_pkg:main 代表 example_pkg 这个包下的 main 函数。
See also:
- Writing your pyproject.toml - Python Packaging User Guide
- Creating projects | uv
- PEP 621 – Storing project metadata in pyproject.toml | peps.python.org
创建一个项目后,为我们生成了一个命令叫做 example-pkg 要运行这个 example-pkg 命令我们有两个方式。
(1) 通过 uv run 来运行我们的 example-pkg。- ~/example-pkg (master)> uv run example-pkg
- Using CPython 3.11.11 interpreter at: /usr/bin/python3.11
- Creating virtual environment at: .venv
- Built example-pkg @ file:///home/user/example-pkg
- Installed 1 package in 0.75ms
- Hello from example-pkg!
复制代码 (2) 可以激活 uv 给我们创建好的虚拟环境,再运行我们的 example-pkg,我们就不需要通过 uv run 来运行这个命令了。uv run 的解释见下文。- ~/example-pkg (master)> source .venv/bin/activate.fish
- (example-pkg) ~/example-pkg (master)> example-pkg
- Hello from example-pkg!
复制代码 .venv 会在首次使用 uv run 自动创建。也可以使用 uv run 创建 venv 虚拟环境。uv sync 更新虚拟环境。
通常克隆下别人的项目后在项目的根目录运行 uv sync,uv 就会下载好需要的依赖并创建虚拟环境。
uv init --package 创建项目的时候 uv 给我们创建了一个和项目名字一样的命令。
我们来加一个 hello-pkg 命令。- --- a/pyproject.toml
- +++ b/pyproject.toml
- @@ -11,6 +11,7 @@ dependencies = []
- [project.scripts]
- example-pkg = "example_pkg:main"
- +hello-pkg = "example_pkg:main"
- [build-system]
- requires = ["hatchling"]
复制代码 我们运行 hello-pkg,uv 发现 pyproject.toml 更新后自动重新安装了我们的包。这个包会被安装在 .venv。- ~/example-pkg (master)> uv run hello-pkg
- Built example-pkg @ file:///home/user/example-pkg
- Uninstalled 1 package in 0.81ms
- Installed 1 package in 0.67ms
- Hello from example-pkg!
复制代码 如果你习惯在虚拟环境里面开发,不想每次都运行命令都要使用 uv run,更新 pyproject.toml 后要运行一下 uv sync 更新当前的虚拟环境。
这是你开发的命令程序,你可以把你的程序安装用户的全局。这样就不必激活虚拟环境,也不必使用 uv run hello-pkg。- ~> hello-pkg
- Hello from example-pkg!
复制代码 -e 代表 editable 安装,意味着你改动代码后是不需要重新安装的。如果你更新了 pyproject.toml 或者其他的改动需要重新安装,你可以运行 uv tool upgrade example-pkg。
editable 安装是 pip 也支持的命令,Python 3.6 就能用。
uv run
我的代码可能放在 Python 包里,也可能是一个单独的 .py 脚本,或者是一个脚本没有任何文件扩展名。
uv run 确保你运行的命令/脚本是在一个 Python 的环境中。
当你使用 uv init hello-uv-run 创建了一个项目。- ~ $ uv init hello-uv-run
- Initialized project `hello-uv-run` at `/home/user/hello-uv-run`
- ~ $ cd hello-uv-run/
- ~/hello-uv-run (master) $ eza -l
- .rw-r--r-- 90 user 9 Jun 23:04 main.py
- .rw-r--r-- 158 user 9 Jun 23:04 pyproject.toml
- .rw-r--r-- 0 user 9 Jun 23:04 README.md
复制代码 第一次使用 uv run,uv 创建了虚拟环境,uv 会确保你的 main.py 脚本是在创建的虚拟环境里面运行的。这个时候我们的代码就放在一个单独的 .py 脚本文件里面。- $ uv run main.py
- Using CPython 3.12.8 interpreter at: /usr/bin/python3.12
- Creating virtual environment at: .venv
- Hello from hello-uv-run!
复制代码 我们给这个项目添加一个名为 typer 的依赖。演示一下如何使用库提供的 CLI 工具。- ~/hello-uv-run (master)> uv add typer
- Resolved 10 packages in 13ms
- Installed 8 packages in 17ms
- + click==8.2.1
- + markdown-it-py==3.0.0
- + mdurl==0.1.2
- + pygments==2.19.1
- + rich==14.0.0
- + shellingham==1.5.4
- + typer==0.16.0
- + typing-extensions==4.14.0
复制代码 typer 提供了一个命令行程序 typer。 我们可以使用 typer 来运行我们的 main.py 这个脚本。此时我们没有激活 venv 虚拟环境,运行 uv add 的 typer 命令,要使用 uv run typer。- ~/hello-uv-run (master)> cat main.py
- def main(name: str):
- print(f"Hello {name} from hello-uv-run!")
- ~/hello-uv-run (master)> uv run typer main.py run Joe
- Hello Joe from hello-uv-run!
复制代码 激活 venv 虚拟环境,就可以直接运行 typer 命令。- (hello-uv-run) ~/hello-uv-run (master)> typer main.py run joe
- Hello joe from hello-uv-run!
复制代码 uv run 运行一个命令或者脚本就和你激活虚拟环境后运行命令或者脚本一样。
有时候你需要给命令指定参数,为了不让 uv 误以为参数是 uv run 的,你可以这样:- uv run -- python -m src.example
复制代码 这样就 -m 就会正常传递给 python。如此常见的命令当然 uv 有直接的支持,uv run -m 运行一个 Python 模块。
下面解释一下前面的 typer 命令。typer 命令行程序的用处是:即便你只有一个 .py 脚本也能实现命令行的自动补全,脚本文件名通常不是一个命令的名字。typer --install-completion 安装自动补全后,使用 typer main.py run 就能获得命令行的自动补全。
下面这个例子来自 Typer 的官网。我们将代码保存到 main.py。- import typer
- app = typer.Typer()
- @app.command()
- def hello(name: str):
- print(f"Hello {name}")
- @app.command()
- def goodbye(name: str, formal: bool = False):
- if formal:
- print(f"Goodbye Ms. {name}. Have a good day.")
- else:
- print(f"Bye {name}!")
- if __name__ == "__main__":
- app()
复制代码 因为我们需要直接运行 typer 命令才能获得命令行的自动补全,我们有两个办法:(1) 使用 uv tool install typer 安装 typer 到全局。(2) 激活 venv 虚拟环境,因为我们之前已经使用 uv add typer 将 typer 安装到了虚拟环境。
我们在 Shell 里面敲 typer main.py run 然后按 Tab 键就能得到命令行参数的补全了。- ~/hello-uv-run (master)> typer main.py run
- goodbye hello
复制代码 main.py 只是一个脚本,并不是一个包。一个脚本算不上是一个完整的应用(或者项目),Python 被称之为脚本语言,但 Python 能做的不仅仅是脚本,Python 能开发一个完整的应用,JavaScript/Lua 也是。
单纯的脚本,或许只有 Bash 这样的才算吧。毕竟连 JavaScript 都有模块,有构建,有包(npm 包)。
我们可以通过 Shebang,chmod 赋予脚本可执行权限,把脚本名字的 .py 后缀去掉,手动做到一个脚本看起来是一个独立且完整的命令行程序。但是,这不是我们开发 Python 命令行程序的方式,我们常规的方式是创建一个 Python 的包,使用 entry_point 机制提供命令行程序/GUI程序。uv init --package 就是一个示例。
我一直在强调,你应该用包把 Python 代码组织起来。可我总不能写任何一个 Python 代码都创建一个包吧。
比方说你可能想在你的 Python 项目里面写一个小脚本,测试你的想法,试用一下某个库,或者试试开发的包的某一个函数实现对不对,或者在代码仓库中给出使用你的 Python 包的 Python 脚本示例。
运行你项目里的 .py 脚本你只需要使用 uv run script.py 就可以。
uv run 后面可以指定的是命令/脚本。如果你的脚本是 hello-uv-run/main.py 你只需要 uv run main.py。如果你的脚本的后缀不是 .py,是没有后缀的。你可以加一个 --script 选项。uv run 能自动更新虚拟环境,uv run 能知道你项目的可执行脚本([project.scripts]),uv run 也能帮你运行你不想放在包里面的脚本,哪怕你不想命名脚本为 .py 结尾的文件。uv 帮你处理好环境问题,你不需要提前激活虚拟环境,也不需要提前把你开发的包安装到虚拟环境,只要你使用 uv run 来启动你写的脚本/命令就能找到它应该找到的包。
不要忘了前面提到的方法,你还可以这样来指定运行 Python 脚本时给 Python 的选项。例如:- uv run -- python -i main.py
复制代码 还是那句话,你想要代码被分发,你就要把代码放在一个包里面,有 __init__.py 的文件夹才是 Python 的包。如果一个文件夹里面没有 __init__.py,打包的时候,这个文件夹下的代码就不会被打包,文件夹名字对应的 Python 包也不存在。
如果你的包要提供一个可执行的脚本,就在 [project.scripts] 里面声明一个命令,这个命令对应的是包下的某一个模块的某一个函数。这似乎是唯一的方式。之前的那种在包之外创建一个脚本调用包是过时的做法。不管你是使用 setup.py 还是 pyproject.toml,现在推荐的做法都是声明一个 entry_points。
我问了 DeepSeek,为什么现在的 pyproject.toml 不支持单独写一个脚本作为命令执行的入口。下面是它的回答。
现代 Python 项目更倾向于使用 entry_points 和包内 CLI 代码,因其在兼容性、维护性和工具链支持上的显著优势。
通过 setup.py 或 pyproject.toml 的 entry_points 机制,可以直接将包内的函数注册为命令行工具,无需单独维护脚本文件。
一些老的 setup.py 里面会看到 setup 传一个 scripts 参数,指定一个单独的 python 脚本作为命令行程序的入口,这在现代 python 是不推荐的做法。
TIPS:
开发的时候希望从环境变量读取 API KEY 或者数据库的连接地址和密码。创建一个 .env 文件,uv run 支持从指定文件读取环境变量。- uv run --env-file .env main.py
复制代码 如果你使用的是 Fish shell,你可以通过环境变量设置 uv 默认的 ENV_FILE。uv tool
如果你只想临时运行一个由 Python 包提供的命令,你可以使用uv tool run 是临时安装命令到一个隔离的环境中。uv tool install 可以取代 pipx。
如果你不想激活虚拟环境也不想切换你的工作目录,你就想运行一下本地计算机上你开发的某个 Python 包提供命令。- uv tool run --from <package path> package-command
复制代码 是的,这非常有用,尤其是你想运行一个命令,但是又不想安装它。不管这个命令从网上能下载到的,还是在你本地计算机上暂时不安装到全局的。
如果你要使用 pip 这个包管理器安装 cmake/meson,你不应该使用系统自带的 pip,因为这会污染系统的 Python 环境,安装一个独立的应用,应该使用 pipx 而不是 pip。不过现在有 uv 这样的工具,估计也不太会再使用 pipx 了。
你开发的命令行工具可以直接使用 uv tool install 安装,只需指定 git 的 clone 地址。uv workspace
创建一个 pyproject.toml 表示这个目录是一个 Python 项目。- $ uv init --bare example
- Initialized project `example`
复制代码 在 example 目录下创建新的 uv 项目都会自动更新 example/pyproject.toml, 一个项目依赖 workspace 的其他项目可以直接使用 uv add projectname 添加,就像这个项目已经放在 PYPI 上一样。
总结
uv 的 CLI 规范和 Go 有点类似,尤其是 go help 和 uv help。uv 的任何命令有不理解的地方可以查看一下这个命令的 help。
如果希望有一个命令行命令,所有 CLI 逻辑应放在包的模块内(如 src/example_pkg/cli.py),通过 __init__.py 定义包,而非分散的脚本文件。或者所有的 CLI 逻辑单独放在一个包里面(如 src/cli/__init__.py)。
uv run 自动处理虚拟环境,直接运行脚本或命令。你可以把代码放在包里,也可以不放在包里。
- uv run script.py 运行项目里面的脚本,script.py 在 src/ 之外。
- uv run --script myscript 运行项目里面的脚本没有 .py 后缀
- uv run -- python -m mymodule 运行虚拟环境的 python 使用 -m 选项,将模块作为脚本运行
- uv run console-script 运行在 [project.scripts] 中声明的 console-script
如果你激活了虚拟环境,你就可以把 uv run 去掉了。如果你使用 uv tool 安装 Python 包,就可以不激活运行 console-script。
旧版 setup.py 中通过 scripts=["scripts/myscript.py"] 的方式已被淘汰。
你想要代码被分发,你就要把代码放在一个包里面,有 __init__.py 的文件夹才是 Python 的包。
本文就先写到这里了,有了这些知识后相信你已经知道如何在用 Python 开发应用/库的过程使用 uv 了。尤其是强大的 uv run 让你的代码随意组织,又能方便运行。在使用 uv 的项目中,你可以又脚本,也可以有包,可以将一个脚本慢慢变成包。uv 一定会成为你 Python 开发过程中一件趁手的工具。
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |