2026年7月20日,更新了异常处理相关的内容,与测试中的断言异常相对应。
书承上文,在Python中使用自带的 argparse 来解析命令行参数并构建CLI应用其实不太现实,除非你的应用极其简单。因此我在一段时间的开发后就将命令行解析工具从 argparse 换到了 Typer,这是一个非常简单且易用的命令行解析工具,允许你创建两层子命令rich 的美观命令行输出。我们直接来看看如何使用 Typer 构建命令行程序。
如果你有什么不清楚的地方,可以看看 Typer 的官方文档, 或者国内镜像,里面内容也很清楚,但由于 Typer 的配置过于灵活,我在这篇文章给出了一个基础的构建范例。
创建主程序入口
与 argparse 不同,Typer 虽然也可以使用显式方法创建命令行解析,但一般来说都使用装饰器来创建命令行解析的,基本代码如下:
import typerfrom typing_extensions import Annotated
app = typer.Typer( no_args_is_help=True, # 这个flag用来设置当没有程序没有任何输入时,返回help。 help="your program help" # 这里填入你的主程序帮助。)
@app.command(help="your sub command help") # 这个help填入你的子命令帮助,下面的help则是参数和选项的帮助。def hello(name: Annotated[str, typer.Arguement(help="your name")]): # Typer建议采用类型标注来规定输入参数,不用也可以。 print(f"Hello {name}.")
@app.command(help="your another command help")def goodbye(name: str = typer.Arguement(..., help="your name")): # 不用类型标注则是这种形式。 print(f"Goodbye {name}.")
def main(): app()
if __name__ == "__main__": main()上面演示的程序拥有两个子命令,你可以按如下所示运行这两个不同的功能。
program-name hello lancer_soulHello lancer_soul.
program-name goodbye lancer_soulGoodbye lancer_soul.如果你的程序不需要任何子命令,Typer 也可以自动识别,你只需要像上面一样创建命令就行了。
import typer
app = typer.Typer( no_args_is_help=True, # 这个flag用来设置当没有程序没有任何输入时,返回help。 help="your program help" # 这里填入你的主程序简介。)
@app.command(help="your sub command help") # 当程序只有一个子命令时,这里不需要填帮助。def hello(name: Annotated[str, typer.Arguement()]): print(f"Hello {name}.")
def main(): app()
if __name__ == "__main__": main()当你在程序入口只创建了一个子命令的时候,Typer 就不会为你的程序创建任何子命令,你可以直接运行主程序。
program-name lancer_soul Hello lancer_soul.将子命令函数与主函数分开
当我们的子命令比较多的时候,我们可能希望将子命令的创建与主程序分开来分模块编写。这个时候我们不再用 @app.command() 装饰器来创建命令,而是使用显式方法来创建。
import typerfrom program_name.command.hello import hellofrom program_name.command.goodbye import goodbye
app = typer.Typer()app.command(help="your sub command help")(hello)app.command(help="your sub command help")(goodbye)
def main(): app()
if __name__ == "__main__": main()在另一个文件中我们定义真正的要用到的子命令入口。
import typer # 在定义子命令入口的时候,其实隐性使用了typer,不会提示无效引用from typing_extensions import Annotated
def hello(name: Annotated[str, typer.Arguement()]): print(f"Hello {name}.")参数与选项
在应用程序中,传参指的是按一定顺序传入的参数,选项则是按特定名称传入的参数。相比于 argparse Typer 的语法非常简单,只需在创建命令时填入所需参数和选项即可。除此之外,我们也展示了 Typer 在创建参数时的一大优势:被创建的命令行参数是一个变量,而非 args 的 attribute ,可以被直接使用。
import typerfrom typing_extensions import Annotatedfrom typing import Literal
def app( name: Annotated[str, typer.Arguement(help="your name")], fromal: Annotated[bool, typer.Option("--fromal", "-f", help="Say hi formally.")] = False, sex: Annotated[Literal["male", "female"], typer.Option("--sex", "-s", help="male or female")] = "male" ): if fromal: if sex == "male": print(f"Good day Mr. {name}.") else: print(f"Good day Ms. {name}.") else: print(f"Hello {name}.")从上面的例子我们可以发现,相比 argparse 的显式定义各种参数,Typer 使用声明式定义各类参数,只是在定义具有规定参数的选项时需要用到 Literal 类。与 argparse 类似,Typer 也是通过参数/选项是否具有默认值来确定其是否必需。
进度条
当你使用 uv 的时候可能已经发现它具有非常好看的进度条,这实际上是 Typer 的依赖:rich 的功能,在使用 Typer 的时候不妨试试它。Typer 本身也提供了一个 typer.progressbar 的接口,但 Typer 更建议直接使用 rich。rich 的进度条由一个叫做 track 的函数提供,如果只需要简单使用的话,导入该函数就行。进度条显示的是迭代对象的进度,所以我们只需要使用 track 将迭代对象包裹起来就可以了。
import timefrom rich.progress import trackfor i in track(range(20), description="Processing..."): time.sleep(0.1)print("Process done.")除了这种进度条,你应该还见过那种旋转的进度,它一般用于表示无法计算进度的操作,只是为了告诉用户这项操作正在进行。
import timefrom rich.progress import Progress, SpinnerColumn, TextColumn
with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), transient=Ture,) as progress: progress.add_task(description="Processing...", total=None) time.sleep(3)print("Process done.")上述内容已经用到了进度显示的高级用法,这里的 Progress 类是自定义的,如果你不喜欢这个款式,可以在 rich 的用户手册中查看具体的自定义选项。
异常处理方法
如果不使用任何机制隐藏或处理Python的异常信息,Python会将默认的错误栈直接发送到输出中。控制台直接显示默认的错误栈信息既显得不专业也无法给用户提供直观的信息,因此我们希望将异常信息包装为更易读的形式。Python中通常使用 try...except 来进行异常处理,这是Python处理异常的基本方法。
import loggingtry: from scipy import constantsexcept ImportError as e: print(f"{e.name} is not in the library, please check package.") logging.error(f"{e.name} is not in the library, please check package.")这里使用 except 捕获了 try 中可能发生的 ImportError 异常,并阻止其输出默认错误栈,转而使用 print 函数输出异常提示信息,或者使用 logging 记录错误。
除了处理默认异常,我们可能还需要自行判断过程中可能出现的错误,并创建异常类进行判断处理。异常类的创建处理方式如下:
class MissingValueError(Exception): def __init__(self, message, value=None, list=None): super().__init__(message) #调用父类的初始化方法 self.value = value #初始化异常信息中的值 self.list = list #初始化异常信息中的值 def __str__(self): return f"{self.value} is not in {self.list}"
def example: list = ["a", "b", "c"] if "d" not in list: raise MissingValueError(value="d", list=list)异常类是通过继承Python中大部分异常类的父类 Exception 创建的,他通过一个 __init_ 函数来进行初始化,以及一个 __str__ 函数来修改异常的描述信息。内置异常的 __self__ 信息由于描述太宽泛而无法使用,但自定义异常的描述信息可以直接在异常处理中使用。
import loggingtry: example()except MissingValueError as e: print(e) #自定义异常的描述信息可以直接在异常处理时调用,避免重复书写 logging.error(e)测试
测试是一个非常重要的步骤。在 argparse 的文章中,我们是直接使用命令行来测试的,这不是不可以,但我们有更好的办法。pytest 是一个广泛使用的 Python 测试工具,你只需要运行一个函数,并断言其应当出现的结果。运行 pytest 后,你就能知道该函数有没有通过测试。测试文件就放在之前的 test 目录下。为了让我们的项目能够使用 pytest 来测试,我们需要将真正负责业务的函数与函数命令行入口分离,将业务函数拆分成独立的模块。就像这样:
import typer # 在定义子命令入口的时候,其实隐性使用了typer,不会提示无效引用from typing_extensions import Annotatedfrom program_name.fullname import fullname
def hello( firstname: Annotated[str, typer.Arguement()], lastname: Annotated[str, typer.Arguement()],): name = fullname(firstname, lastname) print(f"Hello {name}.")def hi(firstname, lastname) return firstname + " " + lastname这样,我们就能单独测试 hi 这个函数,在 test 目录下来创建一个测试脚本。
from program_name.fullname import fullname
def test_fullname(): assert fullname("John", "Smith") == "John Smith"如果要测试你的函数是否如期抛出异常,那么可以这么写:
from program_name.fullname import fullname
def test_fullname(): with pytest.raises(TyperError): fullname("John", 250)结语
对于一个大型项目或多人合作项目来说,使用 pytest 进行自动化测试是非常有必要的,加上多人合作编辑的需要,项目的模块化变得必须了起来。如果你在编写项目时能够按照 Typer 和 pytest 的规范进行编写,在后续真正上手工作项目时就能提前适应。