用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, extensionsNone, exclude_dirsNone): 扫描目录返回 {文件路径: 行数} 的字典 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.pyimport click from pathlib import Path from line_counter.scanner import scan_lines from line_counter.formatter import format_results click.command() click.argument(directory, typeclick.Path(existsTrue), default.) click.option(--extensions, -e, multipleTrue, help文件扩展名如 .py .js可多次使用) click.option(--exclude, -x, multipleTrue, help排除的目录名可多次使用) click.option(--format, -f, typeclick.Choice([table, json, csv]), defaulttable, help输出格式) click.option(--no-progress, is_flagTrue, 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()这里multipleTrue允许用户--extensions .py --extensions .js比逗号分隔更符合 Unix 哲学。而且click.Path(existsTrue)自动验证路径避免了手写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_formattable): total_lines sum(data.values()) file_count len(data) if output_format table: console Console() table Table(titlef代码统计 - 共 {file_count} 个文件{total_lines} 行) table.add_column(文件, stylecyan) table.add_column(行数, justifyright, stylegreen) # 按行数降序排列让大文件排前面 sorted_data sorted(data.items(), keylambda 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}, indent2)) 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, extensionsNone, exclude_dirsNone): 生成 (文件路径, 行数) 的生成器方便进度条更新。 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(encodingutf-8, errorsignore).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}%), transientTrue, # 完成后消失不占用终端行 ) as progress: task progress.add_task([cyan]扫描文件..., total100) for path, lines, processed, total in scan_lines_with_progress(directory, ext_set, exclude_set): result[path] lines # 更新进度条 progress.update(task, completedprocessed, totaltotal) # 最后确保显示100% progress.update(task, completedtotal) format_results(result, format)进度条是一个 CLI 工具的“呼吸灯”。没有它用户会切换到其他窗口怀疑工具是不是死循环了。有了它用户会安心地看着进度条跑完然后眼前一亮哇三秒扫完一万个文件。包管理让工具可被 pip 安装setup.py是最容易被忽视的环节但它决定了工具是否能够真正被分享。现代 Python 推荐pyproject.toml但为了兼容性我仍然用setup.py展示关键点from setuptools import setup, find_packages setup( nameline-counter, version0.1.0, packagesfind_packages(), include_package_dataTrue, install_requires[ click8.0, rich10.0, ], entry_points{ console_scripts: [ line-counterline_counter.cli:cli, ], }, authorYour Name, descriptionCount lines of code in a directory, python_requires3.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的existsTrue已经做了路径校验但如果你单独调用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(encodingutf-8, errorsignore).splitlines()) except: return path, 0 with ThreadPoolExecutor(max_workers8) 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选项输出发送异常明细。一个命令行工具的进化就是从“能用”到“好用”再从“好用”到“被用户喜爱”。现在打开终端开始你的第一个真正意义上的命令行工具吧。