用Python开发一个命令行工具:完整实战过程
你敲下python main.py的时候,真的只是跑了一个脚本吗?不,那其实是职业分水岭。一个能交付给同事、发布到 PyPI、甚至被成千上万人pip install的命令行工具,和随便写写的if __name__ == '__main__'之间,隔着完整的工程思维。今天我们就用一次从头到尾的实战,把这条路走一遍。
我们选一个实在的需求:统计项目代码行数的命令行工具。类似cloc,但更轻量,并且完全由你掌控。这个工具叫line-counter,它要支持递归扫描目录、排除特定文件或目录、输出表格或 JSON,还要有进度反馈。相信我,完成它之后,你会发现自己对 Python 的理解提升了一个档次。
从零搭建项目脚手架
别急着写逻辑,先搭骨架。一个好的命令行项目,结构清晰是第一位的。在项目根目录下创建:
line-counter/ ├── line_counter/ │ ├── __init__.py │ ├── cli.py # 命令行入口 │ ├── scanner.py # 扫描逻辑 │ └── formatter.py # 输出格式化 ├── tests/ │ ├── __init__.py │ └── test_scanner.py ├── setup.py # 或 pyproject.toml └── README.md
我故意把line_counter做成包而不是单个脚本,这是最值得养成的习惯之一。未来任何扩展——添加注释过滤、支持更多语言——都不会让你陷入面条式代码。包结构天然隔离关注点,测试和复用都变得简单。现在,初始化__init__.py为空,我们开始写核心。
扫描器的职责:一口气读遍所有文件
scanner.py是引擎,它要接收一个路径,递归找到所有目标文件,统计行数。但硬编码文件扩展名非常不优雅,我们把配置外置。
一个典型的设计模式:扫描器只负责遍历和计数,不关心展示。这样scanner.py可以独立测试:
import os from pathlib import Path def scan_lines(directory, extensions=None, exclude_dirs=None): """ 扫描目录,返回 {文件路径: 行数} 的字典 """ exclude_dirs = exclude_dirs or {'.git', '__pycache__', 'node_modules', '.venv'} extensions = extensions or {'.py', '.js', '.ts', '.java', '.c', '.cpp', '.md'} result = {} for root, dirs, files in os.walk(directory): # 原地修改 dirs 来跳过排除目录 dirs[:] = [d for d in dirs if d not in exclude_dirs] for file in files: ext = Path(file).suffix if ext in extensions: full_path = Path(root) / file try: lines = len(full_path.read_text().splitlines()) result[str(full_path)] = lines except Exception: # 遇到编码问题跳过,别让一个文件搞崩全局 continue return result
看到dirs[:] = [d for d in dirs if d not in exclude_dirs]了吗?这是os.walk隐藏的“快速剪枝”技巧,比手动检查路径快十倍。而且我把默认排除目录写成了集合,集合查询 O(1),比列表快。每一处微小的性能优化,在扫描几十万文件时会变成质的差距。
但你注意到缺陷了吗?read_text()默认用系统编码,Windows 下可能遇到cp1252。所以我们得加入编码检测,或者至少给用户选择权。这个缺陷留到后面用click的参数扩展解决。
命令行接口:从 argparse 到 click
我知道很多人用argparse,但click才是现代命令行工具的标配。它用装饰器声明参数,自动生成帮助信息,自带彩色输出。只需一个@click.command(),你的工具就能拥有生产级的 CLI 体验。
安装click和rich(用于进度条和表格),然后写cli.py:
import click from pathlib import Path from line_counter.scanner import scan_lines from line_counter.formatter import format_results @click.command() @click.argument('directory', type=click.Path(exists=True), default='.') @click.option('--extensions', '-e', multiple=True, help='文件扩展名,如 .py .js,可多次使用') @click.option('--exclude', '-x', multiple=True, help='排除的目录名,可多次使用') @click.option('--format', '-f', type=click.Choice(['table', 'json', 'csv']), default='table', help='输出格式') @click.option('--no-progress', is_flag=True, help='隐藏进度条') def cli(directory, extensions, exclude, format, no_progress): """统计指定目录下的代码行数。""" # 合并默认配置与用户传入 ext_set = set(extensions) if extensions else None exclude_set = set(exclude) if exclude else None # 扫描(带进度条逻辑) result = scan_lines(directory, ext_set, exclude_set) # 格式化输出 format_results(result, format) if __name__ == '__main__': cli()
这里multiple=True允许用户--extensions .py --extensions .js,比逗号分隔更符合 Unix 哲学。而且click.Path(exists=True)自动验证路径,避免了手写os.path.exists的冗余。
但扫描还没有进度反馈。如果扫描/usr/lib下几万个文件,用户会怀疑程序卡死。命令行工具最大的忌讳就是沉默。我们稍后加入进度条。
格式化输出:让数据说话
formatter.py负责把扫描结果变成人类或机器可读的形式。我建议支持三种:表格(用rich.table)、JSON、CSV。输出格式可切换,这是优秀的 CLI 工具必备的灵活度。
from rich.console import Console from rich.table import Table import json import csv import sys def format_results(data, output_format='table'): total_lines = sum(data.values()) file_count = len(data) if output_format == 'table': console = Console() table = Table(title=f"代码统计 - 共 {file_count} 个文件,{total_lines} 行") table.add_column("文件", style="cyan") table.add_column("行数", justify="right", style="green") # 按行数降序排列,让大文件排前面 sorted_data = sorted(data.items(), key=lambda x: -x[1]) for path, lines in sorted_data: table.add_row(path, str(lines)) console.print(table) elif output_format == 'json': print(json.dumps({'files': data, 'total_lines': total_lines, 'file_count': file_count}, indent=2)) elif output_format == 'csv': writer = csv.writer(sys.stdout) writer.writerow(['文件路径', '行数']) for path, lines in data.items(): writer.writerow([path, lines])
这段代码里,按行数降序输出是一个很不起眼但很贴心的设计。用户在表格第一眼就看到最大的文件,方便定位“庞然大物”。细节决定工具的‘人味’。
加进度条:用rich.progress安抚用户
scan_lines目前没有进度反馈,我们重构它。在scanner.py里加入进度上下文管理器。为了保持函数纯净,我把它写成生成器模式,让 CLI 层控制进度条:
import os from pathlib import Path from rich.progress import Progress, BarColumn, TextColumn def scan_lines_with_progress(directory, extensions=None, exclude_dirs=None): """ 生成 (文件路径, 行数) 的生成器,方便进度条更新。 """ exclude_dirs = exclude_dirs or {'.git', '__pycache__'} extensions = extensions or {'.py', '.js', '.ts', '.java', '.c', '.cpp', '.md'} total_files = 0 # 先数一下有多少目标文件(用于进度条总数) for root, dirs, files in os.walk(directory): dirs[:] = [d for d in dirs if d not in exclude_dirs] for file in files: if Path(file).suffix in extensions: total_files += 1 # 再遍历一次并计数 processed = 0 for root, dirs, files in os.walk(directory): dirs[:] = [d for d in dirs if d not in exclude_dirs] for file in files: ext = Path(file).suffix if ext in extensions: full_path = Path(root) / file try: lines = len(full_path.read_text(encoding='utf-8', errors='ignore').splitlines()) except Exception: lines = 0 # 忽略无法读取的文件 processed += 1 yield str(full_path), lines, processed, total_files
然后在cli.py里用Progress包装:
from rich.progress import Progress, BarColumn, TextColumn from line_counter.scanner import scan_lines_with_progress @click.command() # ... 参数同上 ... def cli(directory, extensions, exclude, format, no_progress): ext_set = set(extensions) if extensions else None exclude_set = set(exclude) if exclude else None result = {} if no_progress: # 无进度条,直接调用原始扫描(不用 scan_lines_with_progress 的第一次遍历) # 但为了简化,可以重用带进度的函数但隐藏进度条 pass with Progress( TextColumn("[progress.description]{task.description}"), BarColumn(), TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), transient=True, # 完成后消失,不占用终端行 ) as progress: task = progress.add_task("[cyan]扫描文件...", total=100) for path, lines, processed, total in scan_lines_with_progress(directory, ext_set, exclude_set): result[path] = lines # 更新进度条 progress.update(task, completed=processed, total=total) # 最后确保显示100% progress.update(task, completed=total) format_results(result, format)
进度条是一个 CLI 工具的“呼吸灯”。没有它,用户会切换到其他窗口,怀疑工具是不是死循环了。有了它,用户会安心地看着进度条跑完,然后眼前一亮:哇,三秒扫完一万个文件。
包管理:让工具可被 pip 安装
setup.py是最容易被忽视的环节,但它决定了工具是否能够真正被分享。现代 Python 推荐pyproject.toml,但为了兼容性,我仍然用setup.py展示关键点:
from setuptools import setup, find_packages setup( name='line-counter', version='0.1.0', packages=find_packages(), include_package_data=True, install_requires=[ 'click>=8.0', 'rich>=10.0', ], entry_points={ 'console_scripts': [ 'line-counter=line_counter.cli:cli', ], }, author='Your Name', description='Count lines of code in a directory', python_requires='>=3.8', )
关键就在entry_points里的console_scripts。这一行让你的工具从python -m line_counter.cli变成直接敲line-counter命令,如同ls或grep一样自然。安装后,任何终端都能调用line-counter . --format json。这才是真正的命令行工具。
测试:别让 bug 在用户机器上爆炸
我建议至少测试扫描器的核心逻辑。用pytest配合临时目录:
import tempfile import os from line_counter.scanner import scan_lines def test_scan_lines_basic(): with tempfile.TemporaryDirectory() as tmpdir: # 创建测试文件 file1 = os.path.join(tmpdir, 'test.py') with open(file1, 'w') as f: f.write('line1\nline2\nline3\n') file2 = os.path.join(tmpdir, 'data.txt') # 默认不扫描 with open(file2, 'w') as f: f.write('a\nb') result = scan_lines(tmpdir) assert file1 in result assert result[file1] == 3 assert file2 not in result
测试可以逼迫你写出更健壮的代码。例如,如果用户传入不存在的目录,扫描器应该报错还是返回空?click的exists=True已经做了路径校验,但如果你单独调用scan_lines呢?在模块层也加一段防御代码:
def scan_lines(directory, ...): if not Path(directory).is_dir(): raise NotADirectoryError(f"路径不存在或不是目录: {directory}")
进阶:缓存与并行
如果你的工具被用在大型仓库上,每次手动运行都要等待扫描,体验不佳。你可以加入一个简单的缓存文件(.line-counter-cache.json),记录每个文件的修改时间和行数,下次只扫描变更过的文件。缓存是命令行工具进阶的标志。
另外,扫描文件是 I/O 密集型任务,可以用concurrent.futures.ThreadPoolExecutor并行读取。但要注意,os.walk本身是单线程的,我们需要先收集文件列表,再并行读取。基本原理:
from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path def scan_parallel(directory, ...): # 先收集所有目标文件路径 file_paths = [] for root, dirs, files in os.walk(directory): # 同样跳过排除目录 dirs[:] = [d for d in dirs if d not in exclude_dirs] for file in files: if Path(file).suffix in extensions: file_paths.append(Path(root) / file) results = {} def count_lines(path): try: return path, len(path.read_text(encoding='utf-8', errors='ignore').splitlines()) except: return path, 0 with ThreadPoolExecutor(max_workers=8) as executor: futures = [executor.submit(count_lines, p) for p in file_paths] for future in as_completed(futures): path, lines = future.result() results[str(path)] = lines return results
并行读取能让扫描速度提升 5-10 倍,尤其是在 SSD 上。但要注意,线程数不宜超过 CPU 核心数两倍,否则 I/O 瓶颈会变成线程调度开销。
发布到 PyPI(自由加分项)
最后一步,把你的工具公之于众。注册 PyPI 账号,安装twine,在项目目录下运行:
python setup.py sdist bdist_wheel twine upload dist/
这样全世界的人都能pip install line-counter了。把一个随手写的脚本变成可 pip 安装的包,是开发者自信的分水岭。而且你会在发布过程中学会版本管理、README 编写、License 选择,这些能力会反向提升你的代码质量。
总结:持续打磨,工具即你的名片
你可能会说:“行数统计工具已经有很多了,有必要自己写吗?” 但相信我,从零写一个 CLI 工具的完整过程,比背十遍文档更值。你亲手处理了进度条、编码异常、并行、缓存、输出格式、包发布,这些经验会在未来任何一个需要通过命令行暴露的功能点上复用。
最后,别忘了给你的工具加个交互式配置功能,比如用click.prompt让用户首次运行设置默认扩展名;或者加一个--debug选项输出发送异常明细。一个命令行工具的进化,就是从“能用”到“好用”,再从“好用”到“被用户喜爱”。现在,打开终端,开始你的第一个真正意义上的命令行工具吧。
