在构建复杂命令行应用时,很多开发者容易陷入"一个文件搞定所有"的陷阱。我见过不少项目,main.py文件动辄上千行,各种命令、子命令、参数和业务逻辑混杂在一起。这种写法短期内看似方便,但随着功能增加会带来诸多问题:
实际案例:我曾接手过一个用Click编写的内部工具,单个文件包含32个命令和子命令,文件长度达到1800行。光是理解各个命令之间的关系就花了两天时间,更别提后续的功能扩展了。
对于中等复杂度的CLI应用,我推荐以下目录结构:
code复制my_cli/
├── __init__.py
├── __main__.py
├── cli/
│ ├── __init__.py
│ ├── main.py
│ ├── commands/
│ │ ├── __init__.py
│ │ ├── db.py
│ │ ├── user.py
│ │ └── utils.py
│ └── models/
│ ├── config.py
│ └── database.py
├── core/
│ ├── services.py
│ └── exceptions.py
└── tests/
├── test_commands/
└── test_core/
这种结构的关键优势在于:
在cli/main.py中定义顶层Typer应用:
python复制import typer
from .commands import user, db
app = typer.Typer(
name="my-cli",
help="Awesome CLI tool for managing resources",
no_args_is_help=True
)
# 集成子命令
app.add_typer(user.app, name="user", help="User management")
app.add_typer(db.app, name="db", help="Database operations")
# 全局选项回调
@app.callback()
def global_options(
verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose output"),
config: str = typer.Option("~/.config/my-cli", help="Config file path")
):
"""Global options available to all subcommands"""
# 初始化全局状态
from ..core import config
config.init(verbose=verbose, config_path=config)
以用户管理模块为例(cli/commands/user.py):
python复制import typer
from typing import Optional
from datetime import datetime
from ...core.services import UserService
from ...core.exceptions import handle_errors
app = typer.Typer(name="user")
@app.command()
@handle_errors
def create(
username: str,
email: str = typer.Option(..., prompt=True),
is_admin: bool = typer.Option(False, "--admin"),
expires_at: Optional[datetime] = typer.Option(None)
):
"""
Create new user with specified credentials
Examples:
my-cli user create johndoe --email john@example.com
my-cli user create admin --email admin@example.com --admin
"""
user = UserService.create_user(
username=username,
email=email,
is_admin=is_admin,
expires_at=expires_at
)
typer.echo(f"Created user {user.id}: {username}")
创建cli/models/options.py集中管理常用选项:
python复制from typing import Annotated
import typer
from pathlib import Path
VerboseOption = Annotated[
bool,
typer.Option("--verbose", "-v", help="Enable verbose output")
]
ConfigOption = Annotated[
Path,
typer.Option(
help="Config file path",
envvar="MY_CLI_CONFIG",
dir_okay=False,
writable=True
)
]
def get_user_id_argument() -> Annotated[str, typer.Argument(help="Target user ID")]:
return typer.Argument(..., callback=validate_user_id)
对于需要共享状态或服务的命令,可以使用回调依赖:
python复制# cli/dependencies.py
from typing import Annotated
import typer
from ..core.services import DatabaseService
def get_db() -> DatabaseService:
return DatabaseService.current()
DbDep = Annotated[DatabaseService, typer.Option(depends=get_db)]
# 在命令中使用
@app.command()
def list_users(db: DbDep):
for user in db.list_users():
typer.echo(f"{user.id}: {user.name}")
建立可测试的命令结构:
python复制# tests/test_commands/test_user.py
from typer.testing import CliRunner
from my_cli.cli.commands.user import app
runner = CliRunner()
def test_user_create():
result = runner.invoke(
app,
["create", "testuser", "--email", "test@example.com"],
input="y\n" # 处理确认提示
)
assert result.exit_code == 0
assert "Created user" in result.output
大型CLI工具启动慢是常见问题,可以通过动态导入优化:
python复制# cli/main.py
def load_command(name: str):
"""按需加载子命令模块"""
module_map = {
"user": ".commands.user",
"db": ".commands.db"
}
module = importlib.import_module(module_map[name], __package__)
return module.app
@app.callback()
def main(ctx: typer.Context):
# 预加载配置但不加载所有命令
if ctx.invoked_subcommand is None:
return
# 只加载被调用的子命令
command = load_command(ctx.invoked_subcommand)
ctx.obj = command.make_context(ctx.command.name, ctx.args)
对于耗时操作,使用异步命令:
python复制import anyio
import typer
from typing import List
@app.command()
async def batch_process(
files: List[Path] = typer.Argument(..., help="Files to process"),
workers: int = typer.Option(4, help="Number of parallel workers")
):
"""Process multiple files in parallel"""
async with anyio.create_task_group() as tg:
for file in files:
tg.start_soon(process_file, file)
typer.echo(f"Processed {len(files)} files with {workers} workers")
async def process_file(file: Path):
# 实际处理逻辑
...
推荐使用分层配置系统:
python复制# core/config.py
from pydantic import BaseSettings
from pathlib import Path
class Settings(BaseSettings):
app_name: str = "my-cli"
config_path: Path = Path("~/.config/my-cli").expanduser()
db_url: str = "sqlite:///default.db"
class Config:
env_prefix = "MYCLI_"
env_file = ".env"
settings = Settings()
def init_config(config_path: Path = None):
if config_path:
settings.config_path = config_path.expanduser()
# 加载用户配置
user_config = settings.config_path / "config.toml"
if user_config.exists():
settings.update(user_config.read_text())
结构化日志配置示例:
python复制# core/logging.py
import logging
import sys
from typing import Optional
from loguru import logger
def configure_logging(verbose: bool = False, file: Optional[Path] = None):
level = logging.DEBUG if verbose else logging.INFO
logger.remove()
logger.add(
sys.stderr,
level=level,
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>"
)
if file:
logger.add(
file,
rotation="1 week",
retention="1 month",
level=logging.DEBUG,
serialize=True
)
# 在命令中使用
@app.command()
@handle_errors
def run_task(
task_id: str,
verbose: bool = typer.Option(False, "--verbose", "-v")
):
configure_logging(verbose=verbose)
logger.info(f"Starting task {task_id}")
...
统一的错误处理机制:
python复制# core/exceptions.py
from typing import Callable, TypeVar
import functools
import typer
from pydantic import ValidationError
T = TypeVar('T')
def handle_errors(func: Callable[..., T]) -> Callable[..., T]:
@functools.wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except ValidationError as e:
typer.secho(f"Validation error: {e}", fg="red")
raise typer.Exit(1)
except DatabaseError as e:
typer.secho(f"Database error: {e}", fg="red")
raise typer.Exit(2)
except Exception as e:
typer.secho(f"Unexpected error: {e}", fg="red")
raise typer.Exit(3)
return wrapper
在开发企业级CLI工具时,我总结了以下实战经验:
渐进式复杂度:从单文件开始,当命令超过5个或文件超过300行时开始模块化拆分
文档生成:使用typer-cli自动生成文档:
bash复制typer my_cli.cli.main utils docs --name my-cli --output docs/cli-reference.md
Shell补全:为提升用户体验,务必实现shell补全:
python复制# __main__.py
if "__complete__" in os.environ:
from typer.core import get_completion
get_completion(app)
sys.exit(0)
性能监控:对于长时间运行命令,添加耗时统计:
python复制@app.command()
@handle_errors
def long_running():
start = time.monotonic()
# ...执行操作...
elapsed = time.monotonic() - start
typer.echo(f"Completed in {elapsed:.2f}s")
跨平台考虑:处理Windows和Unix差异:
python复制def get_config_path() -> Path:
if sys.platform == "win32":
return Path(os.environ["APPDATA"]) / "my-cli"
return Path.home() / ".config" / "my-cli"
这些实践来自多个生产项目的经验总结,能够显著提升大型Typer应用的可维护性和开发效率。关键是要根据项目规模选择合适的架构,并在复杂度增加时及时重构。