尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

CLI-Anything:用声明式YAML构建命令行工具编译框架

CLI-Anything:用声明式YAML构建命令行工具编译框架 1. 为什么我最终造了个“CLI 编译器”1.1 粘贴复制 argparse 才是最大的重复代码我的工作目录里脚本数量超过两位数之后最先崩掉的不是磁盘空间而是我的耐心。每个脚本都要重复同一套动作接收参数、校验类型、打印帮助、处理异常。今天给 A 脚本加一个--verbose明天给 B 脚本补一个--format json改完还要测试各种参数组合我越来越觉得真正的重复代码不是业务函数而是整个“命令行外壳”本身。于是“CLI-Anything”这个项目名就这么出现了。它的定位很直白把任何一类能力封装成命令行工具不限定是 Python 函数、HTTP 接口、SQL 查询还是外部二进制。你只要给一份声明式描述框架负责生成参数解析、类型校验、帮助文本、自动补全和退出码。说得夸张一点它不是帮你写 CLI 的库而是给 CLI 写的编译器。这个思路最开始是从一次很反直觉的对比里出来的。那时我同时维护三个工具一个定时任务脚本、一个数据导出脚本、一个内部小服务的管理命令。三者语言不同、参数风格不同、输出格式不同但都是在“把某些输入转换成某些结果”。我当时就冒出一个念头与其反复给每个工具写解析器不如定一种通用描述格式把工具自身当作一个可插拔的“执行器”让框架统一处理那些烦人但必须有的细节。CLI-Anything 的核心循环一直没变读描述、建命令树、收集参数、校验、执行、格式化输出。外面看起来就是最普通的命令行工具但所有“壳”都由同一套骨架生成。我在实际使用中最大的感受是改动参数时只需要改 YAML不用动代码新增工具时只需要写适配器和用例不用重造轮子。1.2 “描述”取代“实现”CLI-Anything 的最初原型第一版原型只有两百行代码思路非常朴素你写一个 YAML里面列出命令名、参数、执行目标我按这个描述生成一个 argparse 解析器。当时的运行目标只支持 Python 模块里的函数因为我自己最常用的是函数调用。command: name: ping description: 测试一个地址是否可达 input: - name: host type: string required: true - name: count type: int default: 4 run: target: python_function module: mytools.network function: ping_host这段描述跑起来之后你能直接得到一个带--host、--count、--help的命令。最让我吃惊的不是它能用而是它几乎不费力气就拿到了跨工具一致性所有命令的帮助格式长得一样参数错误提示的风格统一退出码规则一致。后来我逐渐把“函数调用”这个目标扩展为“适配器”才真正开始包住各种常见场景。现在回看这个项目最大的价值不是省了多少行代码而是建立了一个“命令层的叙事方式”。团队里的人看到一个 YAML就知道这个命令能干什么、要传什么参数、输出会长什么样看到一个新的适配器就知道如何把别的系统接进来。下面几章我会把整个架构拆开讲包括描述层、适配器层、内置机制以及我从真实使用里踩出来的一堆坑。2. 描述层如何让一份普通 YAML 变成完整命令树2.1 最小示例一个命令只描述 name、description、input、runCLI-Anything 所有功能的入口都叫“命令描述”简称 CD。一份 CD 至少包含四部分命令叫什么、命令有什么用、需要哪些输入、最终要执行什么。command: name: datex description: 日期转换与偏移计算 input: - name: date type: datetime required: true help: 原始日期格式 2025-04-01 - name: offset type: int default: 0 help: 增加或减少的天数负数表示往前 run: target: python_function module: myutils function: shift_date框架加载这份 CD 后会生成一棵命令树并把输入参数注册成解析项。你需要知道的第一个原则是input 里的每一项最终都会映射到命令行的一个--name参数而 name 本身就是这个参数的唯一定义它不需要别的别名。对于布尔类型映射成--flag后不需要传值对于文件类型框架会自动检查路径存在性。第二个原则是run 段的 target 决定用哪个适配器。CLI-Anything 默认内置函数、HTTP、SQL、Shell 四种适配器target 就是适配器注册名。如果你在自己的项目里引入了 CLI-Anything 作为库也可以注册自定义 target后面章节我会给自定义适配器的实际代码。2.2 类型推导的顺序显式声明、函数注解、默认值如果 YAML 里写了type框架直接采用如果没有显式声明而目标是 Python 函数适配器会读取函数签名里的类型注解注解也没有再退回默认值的实际类型。这个推导顺序我建议不要乱调因为显式声明最稳定函数注解适合快速开发默认值推导则只是因为“兜底”。需要小心的是 bool 类型。Python 里bool(False)是 True所以框架对布尔参数做了专门处理只接受true/false/1/0/yes/no这几个字面量避免用户写--flag False时踩惊天大坑。下表是通用类型和命令行外观的对应关系YAML type命令行表现Python 目标类型string直接接收文本strint整数自动校验范围intfloat浮点数自动校验范围floatbool--flag或--flagtruebooldatetime接受2025-04-01T10:30:00格式datetimepath自动展开~解析相对路径Pathjson接收 JSON 字符串解析成 dict/listobjectenum只允许枚举值列表中的字面量strlist可重复传参逗号分隔也能拆list这个表越往后越是 CLI-Anything 相对原生 argparse 的优势所在。原生 argparse 里path 类型只是字符串json 类型要自己写转换函数enum 要再包一层choices。在描述层统一之后这些转换规则变成所有命令共享的默认行为新加一个命令的成本被压得非常低。2.3 嵌套子命令的三个约定CLI-Anything 支持多级子命令但保留了三个硬性约定避免设计失控第一一个 CD 文件代表一个叶子命令不能在一个文件里塞两个没有关系的动作。第二如果一个目录下有多个 CD 文件目录名自动变成命名空间前缀例如user/list.yaml会生成user list这条命令。第三只有一个执行入口任何层级的命令都要落在某个 target 上不允许出现“目录节点也有执行逻辑”的情况。约定的好处是生成 shell 补全时能穷举命令树不用猜测。实际项目里我一般按照“业务领域/动作”组织文件billing/list.yaml、user/create.yaml。这样无论命令数量增长多少命名空间不会乱。在内部实现上框架会把 YAML 文件解析成字典然后构建一棵CommandNode树。CommandNode负责把子节点注册成 argparse 的 subcommand把 input 注册成参数再把 run 配置交给适配器。你不需要知道这棵树多深只要记得叶子节点的 run 才是真正干活的部分其他层级的 run 是不允许写的。3. 适配器设计函数、HTTP、SQL 三种形态的归一化3.1 函数适配器用 inspect 读取参数签名所有适配器都遵循同一个极小协议能描述自己能接收什么参数能执行实际调用能提前做一次无害的演练。函数适配器是第一个实现的它把 Python 函数当成最终目标。class PythonFunctionAdapter: def __init__(self, module_name, func_name): self.module_name module_name self.func_name func_name self._func None def describe(self): import inspect obj self._load() sig inspect.signature(obj) result [] for name, param in sig.parameters.items(): result.append({ name: name, default: param.default, annotation: param.annotation, }) return result def execute(self, params): fn self._load() return fn(**params) def _load(self): import importlib if self._func is None: mod importlib.import_module(self.module_name) self._func getattr(mod, self.func_name) return self._func这里最核心的是describe()返回的参数列表CLI-Anything 会拿它补全 YAML 里没写的 type、default 和 required。执行时则直接把解析好的字典**params丢给函数。这种设计让已有业务函数可以零侵入接入不需要在函数里调框架任何东西。3.2 HTTP 适配器把 OpenAPI 描述变成子命令我大量用到“把内部 HTTP 接口包装成 CLI”的场景。HTTP 适配器建议接入一份 OpenAPI 描述文件框架根据路径和方法自动生成子命令。以只读接口为例默认只允许 GET 方法只有显式配置才允许 POST。command: name: todo description: 与本地待办服务交互 adapter: http openapi: http://127.0.0.1:8000/openapi.json method: GET path: /todos output: table执行cx todo list时适配器会把子命令名和路径绑定再根据 OpenAPI 里的参数定义把 query 参数暴露为命令行参数。返回体如果是 JSON输出层负责渲染成表格或纯文本。这个适配器的设计重点是隔离CLI 只管请求和展示不关心服务内部实现。实操中你不需要害怕路径里有复杂嵌套例如/users/{id}/orders。CLI-Anything 会把路径参数{id}转成--id的必填参数和普通 query 参数放一起。这样即使是专为前端设计的接口也能用命令行完成调试。3.3 SQL 适配器查询文件加参数模板不做 ORMSQL 适配器的设计原则是只做参数绑定和结果展示不提供增删改的通用入口。我把连接信息固定成只读连接执行的 SQL 必须来自预先声明好的sql_file不允许用户直接在命令行里面传一段 SQL。command: name: monthly-report description: 生成指定月份的销售汇总 adapter: sql connection: sqlite:///sales.db sql_file: queries/monthly.sql input: - name: month type: string required: true output: tablemonthly.sql里只写 SQL参数用{{ month }}占位SELECT strftime(%Y-%m, order_date) AS month, product_name, SUM(amount) AS total_amount FROM orders WHERE strftime(%Y-%m, order_date) {{ month }} GROUP BY product_name ORDER BY total_amount DESC;绑定参数时框架会对{{ }}做转义处理不能把占位符当成普通字符串拼接。实际执行时使用参数化查询既照顾了 SQL 注入风险也能让数据库缓存执行计划。你只要保证queries目录里的文件都是内部可控的这个工具就非常稳。4. 那些“看不见但很加分”的内置机制4.1 自动补全和 help人的时间比协议格式值钱CLI-Anything 内置两套帮助机制一套是--help时打印的完整说明另一套是 shell 补全。补全脚本生成不需要额外安装点框架会把命令树里的叶子命令、参数名、枚举值导出成固定格式再转换成 bash/zsh 的补全函数。设计的时候我坚持一个原则帮助信息不能只写参数类型必须能看到默认值和可选项。比如枚举值参数直接列出可接受的值文件参数说明是否已存在。这样使用者不用去翻文档就能自己摸索完整命令。自动补全里最容易被忽略的是“目录命名空间补全”。如果命令是billing list --status pending那么你敲cx billing之后按 Tab应该只提示list和这个命名空间下的其他叶子命令而不是像某些工具一样把所有子命令一股脑列出来。实现方式就是遍历CommandNode的树结构只给当前节点相邻层级的候选。4.2 三种输出模式human、table、jsonCLI-Anything 的默认输出是 human 模式适合人眼阅读table 模式适合多个结果列json 模式适合程序消费。你可以在 CD 里声明默认 output也可以在命令行用--output json覆盖。human 模式会丢掉一些结构化字段只输出关键信息table 模式会做列宽计算避免中文被截成乱码json 模式输出json.dumps(result, ensure_asciiFalse, indent2)保证 Unicode 可读。这里有个细节如果输出内容包含大段文本比如一段日志或者一个对象详情我通常建议默认用 json否则脚本解析起来会痛苦。输出层还有个和错误处理相关的设计任何层层都往 stderr 写过程日志stdout 只留给最终结果。这样做是为了让cx report --month 2025-04 result.json能拿干净结果而不是混入一堆提示信息。4.3 退出码和 stderr让脚本敢去编程性使用这个 CLI我一直觉得如果一个命令行工具只给人用、不给脚本用那它只能算半个工具。CLI-Anything 固定了一套退出码约定成功返回 0参数错误返回 2目标执行失败返回 3用户中断返回 130。这套约定记录在帮助文档里所有适配器共用。参数错误和业务错误分开是关键。参数错误是调用者写的命令不合法比如类型不对、枚举值非法业务错误是目标本身报错比如数据库连不上、HTTP 返回 500。如果不区分脚本做自动化重试时就会把“写错命令”和“服务故障”混在一起处理。实现上适配器执行异常会被包装成ExecutionError由外层主循环统一处理。主循环捕获到UsageError时打印到 stderr 并返回 2捕获到ExecutionError时返回 3。这个错误分类是我用到现在收益最大的一项设计。5. 三个可复现的实操案例5.1 案例一把一个日期处理函数变成“日期转换命令行”先写一个普通的工具函数# myutils.py from datetime import datetime, timedelta def shift_date(date_str: str, offset_days: int 0) - str: dt datetime.fromisoformat(date_str) return (dt timedelta(daysoffset_days)).date().isoformat()再写对应的 CDcommand: name: datex description: 输出偏移指定天数后的日期 input: - name: date type: string required: true - name: offset type: int default: 0 run: target: python_function module: myutils function: shift_date现在执行$ cx datex --date 2025-04-01 --offset 3 2025-04-04这个案例看起来简单但实际覆盖了参数解析、默认值、函数调用三个环节。如果你以后要把shift_date换成任意本地函数只要保持模块路径能导入就行连测试代码都不用动。5.2 案例二把一个待办列表 HTTP 服务包成五个子命令假设本地有一个待办服务返回 JSON。写一个最简单的只读 CDcommand: name: todo description: 待办列表查询 adapter: http base_url: http://127.0.0.1:8000 mapping: list: method: GET path: /todos get: method: GET path: /todos/{id}mapping的作用是把子命令名和方法路径一一对应。执行$ cx todo list | id | text | done | |----|--------|------| | 1 | 写周报 | false |如果你打开了 OpenAPI 自动映射连mapping都可以省掉框架直接从/openapi.json里生成所有路径。我建议小服务用手写 mapping大服务用 OpenAPI这样每个团队都能按自己的维护习惯来。5.3 案例三把一条 SQL 查询变成月度报表命令继续用上一节的 SQL 适配器。你只需要确认数据库连接串没有写死密码而是通过环境变量读取$ export SALES_DBsqlite:///sales.db $ cx monthly-report --month 2025-04 | month | product_name | total_amount | |---------|--------------|--------------| | 2025-04 | 键盘 | 12800 |我最喜欢这个用法的地方是它把一次手工查询变成了团队里人人可用的命令而且 SQL 文件本身还是纳入版本管理的。你不需要让业务人员会写 SQL只需要让他记得cx monthly-report --month 2025-04。6. 用久了才沉淀下来的十几个坑与默认值6.1 路径参数不能无脑透传第一个坑是路径参数。很多 CLI 新手会把用户输入的路径直接拼进业务代码结果~没展开、相对路径不是从当前目录出发、Windows 路径反斜杠被当成转义。CLI-Anything 里的 path 类型会在解析阶段就做标准化展开~转成绝对路径统一用/分隔并且保留原始输入副本以便调试。如果业务函数接收的是字符串但配置成 path 类型框架会传给str(Path(...))的结果如果业务函数期望Path对象框架直接传入。这个约定避免了“本地能跑、服务器上路径就炸”的问题。提示一下凡是涉及文件读取的命令都尽量把参数声明成 path 而不是 string。6.2 编码、断行与 Windows 终端的温柔陷阱第二个坑来自终端编码。在 Windows 上默认代码页可能是 GBKPython 打印中文时容易遇到UnicodeEncodeError。CLI-Anything 的做法是在入口位置统一设置输出编码if sys.stdout and hasattr(sys.stdout, reconfigure): sys.stdout.reconfigure(encodingutf-8, errorsreplace)如果还遇到更老的环境可以设置环境变量PYTHONUTF81。另一个和断行有关的问题是表格列宽中文字符在终端里宽度算 2不能直接用字符串长度算对齐。CLI-Anything 用了东亚宽度计数否则表格格式会在含中文时乱掉。6.3 不是每次运行都要等真函数dry-run 与超时第三个坑是被长任务卡住。有些命令背后是复杂的本地计算或外部服务调用用户只是想看看参数有没有写对结果直接等了几十秒。CLI-Anything 对所有适配器都实现了--dry-run执行时只打印将要调用的目标、参数映射结果和预计输出格式不真正执行。对于真正需要限时的场景函数适配器和 HTTP 适配器都支持timeout配置。函数适配器会用线程池包一层超时后返回超时错误HTTP 适配器直接使用请求库的超时参数。默认超时我在项目里设置为 30 秒你可以根据业务调整。run: target: python_function module: myutils function: slow_task timeout: 1206.4 白名单和只读默认值CLI 是入口也是边界最后一个坑是能力边界。CLI-Anything 可以被当做一个胶水层但胶水层不能成为后门。所以我在框架里做了几个限制Shell 适配器只允许执行白名单列表内的可执行文件SQL 适配器强制走只读连接HTTP 适配器默认只允许 GET。这三个限制表面上看是收缩了能力实际是保护了使用的人。一个通用工具容易被滥用与其出了问题再补救不如在设计阶段就把高风险动作标成“显式开启”。我的原则是默认最小权限命令如果要执行外部程序必须在 YAML 里写清楚executable名称和允许参数前缀任何没登记的都会在解析时直接拒绝。7. 我对 CLI-Anything 下一步的期待用过一段时间后我最希望它做的事不是继续加适配器而是把“命令描述”变成一种可共享、可组合的标准。比如两个命令之间可以声明依赖关系一个命令执行完自动触发另一个命令又比如把 CD 导出成 JSON Schema这样编辑器可以自动补全 YAML 字段第三方工具也能根据同一份描述生成 Web UI。还有一个我很想要的能力是“会话模式”。现在每个命令都是单次执行但很多运维场景需要先查状态、再改配置、最后再看结果这中间的状态一般放在 shell 环境变量或临时文件里。如果能用会话上下文把多次命令串起来CLI 的脚本化能力会向前一大步。就我个人体会而言CLI-Anything 最让我满意的不是代码量少而是它逼着我用“描述”而不是“命令”去思考工具设计。每当我准备给脚本加一个没有写在 CD 里的参数时第一反应是去修改描述文件而不是立刻动手写解析逻辑。这个小小的思维转换比任何一个具体功能都值钱。
返回列表