news 2026/9/24 17:32:18

【第43期】Python 命令行待办:不用数据库,把列表、文件和异常做成可恢复的 CLI

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
【第43期】Python 命令行待办:不用数据库,把列表、文件和异常做成可恢复的 CLI

【第43期】Python 命令行待办:不用数据库,把列表、文件和异常做成可恢复的 CLI

CSDN 完整教程

系列:《从小白到 AI 大模型开发工程师的进阶之路》
技术点:AI-0131 命令行待办事项
主人公:小蓝伞
前置:AI-0123 文件读写;AI-0130 调试与测试
本期产出:可持久化 CLI 工具

第 42 期把测试习惯立住了。小蓝伞接下来要一个真正能每天用的小工具:待办事项。他第一版把列表只放在内存里,关终端就丢;第二版写入todos.json,从项目根运行和从脚本目录运行时却出现两份内容。问题不是 JSON 偶尔失忆,而是相对路径跟随当前工作目录。本期交付一个支持add/list/done的完整 CLI:路径固定、写入可恢复、坏文件明确失败,并用六组输入验证数据不会在错误分支中被清空。这些习惯会直接影响下一期抓取结果如何可靠落盘。

一、小蓝伞遇到的问题

需求看起来简单:添加、完成、列出、退出后还能看见。真正的坑是:

  • 空输入、重复标题、完成一个不存在的编号
  • JSON 写到一半断电,读回来是半截文件
  • 相对路径依赖当前工作目录,不是脚本所在目录

建议插图 1:内存列表、错误 cwd 下的 json、脚本旁 json 三种结果对照。

二、先给结论

能关终端再打开仍在的数据,才叫持久化;写到哪里,必须由脚本位置决定,而不是你碰巧站在哪个目录。

  1. 用列表保存待办,用 JSON 落盘。
  2. 文件路径用Path(__file__).resolve().parent
  3. 先写临时文件再替换,避免写到一半损坏。
  4. 所有命令先校验输入,再改数据。
  5. 用第 42 期的测试方式覆盖空输入、缺文件、坏 JSON。

三、本文要解决什么

项目内容
目标可持久化 CLI
输入add/list/done/quit 文本命令
输出todos.json,重启后可还原
成功判据正常添加;完成编号越界失败且不丢数据;坏 JSON 可报错不崩溃
不在范围多用户、数据库、Web UI

四、前置准备

python--version mkdir D:\ai-learning\issue-43

不要把 JSON 提交到公开仓库如果里面有私人事项。先备份已有todos.json

五、核心原理

列表适合保留显示顺序,任务对象用字典表示,编号用稳定的整数。这里不用列表下标充当编号:删除第一项后,下标会整体前移,用户手里的“任务 3”可能突然指向另一条。新增编号应取已有最大值加一;完成动作只改done,不悄悄删除数据。

路径有三个概念:Path.cwd()是进程从哪里启动,__file__是脚本在哪里,数据文件是产品约定存在哪里。写Path("todos.json")等于把产品约定交给启动方式;从 IDE、任务计划和终端启动可能落到三个目录。学习项目可把数据目录作为参数传入,入口再默认选择脚本旁的data。这样测试能使用临时目录,生产入口也不依赖用户当前站在哪儿。

直接覆盖还有第二个风险:序列化完成前进程退出,会留下半截 JSON。更稳妥的次序是“同目录临时文件 ->flush->fsync->os.replace”。同一文件系统内替换能避免读者看见半成品,但它不等于数据库事务,也不能解决两个进程同时写。解析失败更不能返回空列表后保存;那会把仍可人工抢救的残骸变成合法空文件。

错误写法与正确写法的差别不是语法风格:

# 错:路径随 cwd 变化,坏文件被伪装成空数据try:items=json.loads(Path("todos.json").read_text())exceptException:items=[]# 对:路径由调用者给出,JSON 损坏向上报告items=load_items(data_path)

六、完整项目

目录结构:

issue-43/ ├── todo.py └── data/ # 首次保存时自动创建

todo.py是可直接运行的完整版本:

from__future__importannotationsfromdataclassesimportasdict,dataclassfrompathlibimportPathimportargparseimportjsonimportosimporttempfile DEFAULT_DATA=Path(__file__).resolve().parent/"data"/"todos.json"@dataclassclassItem:id:inttitle:strdone:bool=Falsedefload(path:Path)->list[Item]:"""从 JSON 加载任务;文件不存在表示首次使用,文件损坏则失败。"""ifnotpath.exists():return[]raw=json.loads(path.read_text(encoding="utf-8"))ifnotisinstance(raw,list):raiseValueError("todos.json 结构必须是列表")items=[Item(**row)forrowinraw]iflen({item.idforiteminitems})!=len(items):raiseValueError("任务编号不能重复")returnitemsdefsave(path:Path,items:list[Item])->None:"""先完整写入同目录临时文件,再原子替换目标文件。"""payload=json.dumps([asdict(x)forxinitems],ensure_ascii=False,indent=2)path.parent.mkdir(parents=True,exist_ok=True)fd,tmp=tempfile.mkstemp(dir=path.parent,suffix=".tmp")try:withos.fdopen(fd,"w",encoding="utf-8")asf:f.write(payload)f.flush()os.fsync(f.fileno())os.replace(tmp,path)exceptException:ifos.path.exists(tmp):os.remove(tmp)raisedefadd(title:str,items:list[Item])->list[Item]:title=title.strip()ifnottitle:raiseValueError("标题不能为空")nid=max((x.idforxinitems),default=0)+1items.append(Item(id=nid,title=title))returnitemsdefdone(item_id:int,items:list[Item])->list[Item]:forxinitems:ifx.id==item_id:x.done=TruereturnitemsraiseValueError(f"没有编号{item_id}")defmain()->int:"""解析子命令,成功返回 0,用户输入错误返回 2。"""parser=argparse.ArgumentParser(description="可恢复的命令行待办")parser.add_argument("--data",type=Path,default=DEFAULT_DATA)commands=parser.add_subparsers(dest="command",required=True)add_parser=commands.add_parser("add")add_parser.add_argument("title")commands.add_parser("list")done_parser=commands.add_parser("done")done_parser.add_argument("id",type=int)args=parser.parse_args()try:items=load(args.data)ifargs.command=="add":save(args.data,add(args.title,items))elifargs.command=="done":save(args.data,done(args.id,items))else:foriteminitems:mark="x"ifitem.doneelse" "print(f"[{mark}]{item.id}:{item.title}")return0except(OSError,ValueError,json.JSONDecodeError)asexc:print(f"错误:{exc}")return2if__name__=="__main__":raiseSystemExit(main())

在脚本目录执行:

python todo.py add"复核第43期"python todo.py add"备份 todos.json"python todo.py list python todo.py done 1 python todo.py list

预期最后两行是[x] 1: 复核第43期[ ] 2: 备份 todos.json。再关闭终端、从另一个目录执行python D:\ai-learning\issue-43\todo.py list,结果应保持不变。需要隔离测试数据时传--data .\sandbox\todos.json,不要拿真实待办做破坏实验。

七、可复现失败案例

项目记录
环节记录
构造方式DEFAULT_DATA临时改成Path("todos.json"),分别从两个目录启动
故障现象两次添加都成功,list却各看到一条不同任务
影响用户误以为保存丢失,继续操作可能覆盖错误文件
最初误判json.dumps没有真正写盘
排查顺序打印 cwd -> 打印脚本目录 -> 搜索同名文件 -> 对比绝对路径
根因相对路径由 cwd 解释,同名文件实际落在两个目录
修复默认路径锚定脚本目录,同时保留--data显式注入能力
复验从两个目录运行都显示相同任务;坏 JSON 返回退出码 2 且原文件不变

这是“可复现失败案例”,不是虚构的生产事故。旧文件若已经散落,先备份并对比内容,再合并,不要看到同名文件就删除。

八、实验设计与数据

实验使用临时目录,不接触真实待办。控制变量是同一脚本和同一输入,只改变启动目录、输入合法性或文件内容;指标是退出码、目标文件路径、写入前后 SHA-256 与任务条数。

用例预期结果成功判据
首次list空输出、退出码 0不要求预先创建文件
添加中文标题写入 1 条文件为 UTF-8,重启可读
空标题退出码 2文件哈希与条数不变
done 999退出码 2其他任务状态不变
内容改成半截[{JSON 解码错误残骸未被覆盖为空列表
从两个 cwd 启动读取同一绝对路径输出任务编号和标题一致

功能实验必须同时覆盖正常、边界和失败。原子替换的“进程在任意时刻崩溃”不能由一次手工强杀充分证明,因此本文只验证临时文件与替换顺序,不把它写成绝对不丢数据。生产系统仍需备份、锁和恢复策略。

九、常见问题与避坑

  1. 把所有异常都当首次运行。文件不存在可以返回空列表;权限拒绝、JSON 损坏、结构错误必须报告。三者语义不同。
  2. eval解析命令。用户输入会进入代码执行环境。这里用 argparse 的固定子命令,未知参数自动拒绝。
  3. 把完成动作做成删除。输错编号时难以恢复。先改状态,真正删除应另设命令和确认。
  4. 让 ID 等于下标。删除会导致身份漂移。ID 稳定、顺序可变,这两个概念要分开。
  5. 多个进程同时写。os.replace只避免半文件,不防最后写入者覆盖前一个。并发需求出现时换 SQLite 或加可靠文件锁。

十、平台、系统与库的差异

pathlib会处理 Windows 与 POSIX 分隔符,代码里不要手拼反斜杠。Windows 上目标文件被某些编辑器占用时,os.replace可能因共享模式失败;Linux 通常允许替换仍被读取的文件名。两边都必须捕获OSError并保留旧文件。PowerShell、CMD、IDE 和任务计划的 cwd 都可能不同,所以路径契约不能依赖终端。单用户单进程 JSON 足够教学;多进程写、条件查询和事务出现时应换 SQLite,而不是继续给 JSON 打补丁。

十一、验证清单

  • 执行两次add后运行list,应看到稳定编号 1、2。
  • 从其他目录以绝对脚本路径运行,输出应与脚本目录启动一致。
  • 执行空标题和done 999,退出码应为 2,原文件内容不变。
  • 把测试副本改成[{,应报告 JSON 错误,文件不能被覆盖成[]
  • 完成编号 1 后重启,编号 2 不应变成编号 1。
  • 用中文标题保存并重开,终端与文件中都应显示原汉字。
  • 搜索工作区内todos.json,除显式测试路径外只应存在约定文件。

从单文件练习推导存储边界

JSON 方案成立有三个前提:单用户、单进程、数据量可一次读入内存。只要其中一个变化,设计就要重新评估。两个终端同时加载相同版本,各自追加任务再保存,后保存者会覆盖前者;原子替换只能保证文件完整,不能合并两份并发修改。最小改进可以在文件中增加版本号,保存前重新读取并做乐观并发检查;真正需要并发、条件查询或事务时,SQLite 比自制锁协议更可靠。

格式也会演进。今天任务只有 id、title、done,明天可能增加 created_at、priority。加载器若直接Item(**row),未知字段和缺失字段都会失败。学习阶段可以严格失败,迫使开发者写迁移;生产升级则应给文件增加schema_version,按版本逐级迁移,并在迁移前备份。不要用“字段没有就给默认值”悄悄吞掉所有差异,因为拼写错误也会被当成旧格式。

恢复策略要回答三个问题:旧文件保留多久,如何识别最近一次有效备份,恢复后如何验证。可以在替换前把目标复制为带时间戳的备份,但备份本身也要有数量上限,且复制失败时不应继续覆盖。本文没有实现轮换,是因为备份策略与用户价值、磁盘空间有关;这属于适用边界,不该用一句“原子写入所以安全”掩盖。

命令行接口也有稳定性。退出码 0 表示成功,2 表示输入或数据错误;人类提示写到标准错误会更便于管道区分,机器可读输出可以另加--json,不要让脚本解析彩色中文句子。--data是依赖注入点,也是测试边界:正式路径、测试临时路径和导入旧文件都经同一入口。路径可配置不等于路径随意,程序仍应打印解析后的绝对路径,让排障者知道正在操作哪份数据。

最后用一次恢复演练收口:复制测试文件,故意截断末尾,确认加载失败且原内容没有被写空;恢复备份后再次 list,编号、标题和完成状态全部一致。演练的证据是文件哈希、退出码和任务数,不是“看起来好了”。做到这里,单文件工具才真正具备可恢复性,而不是只有保存按钮。

测试时如何观察“不写盘”

失败路径最重要的断言不是错误文案,而是状态未被改变。测试可以在临时目录写入一份已知 JSON,记录原始字节;调用空标题、越界编号或坏结构后,再逐字节读取并比较。只比较内存 items 不够,因为错误实现可能先把空列表写盘再抛异常。目标文件内容、临时文件残留、退出码三者都要观察。

对原子保存的正常路径,验证新文件可以再次反序列化、任务数一致、中文未转义丢失,并确认目录里没有遗留.tmp。对模拟替换失败的路径,可以把 replace 封装成可注入函数,由测试让它抛 OSError,断言旧文件仍在、临时文件被清理、异常没有被吞。本文代码直接调用标准库,读者扩展测试时再做这个小重构,避免为了测试把生产逻辑复制一份。

数据边界还包括文件权限。只读目录、磁盘空间不足和防病毒软件占用都可能让保存失败。程序应把 OSError 转成人能理解的失败并返回非零退出码,绝不能先删除旧文件再尝试新建。Windows 文件占用行为与 Linux 不同,这正是为什么“同目录写临时文件并替换”要在目标系统实测。

最后区分业务失败与程序缺陷:空标题、编号不存在、JSON 损坏属于预期失败,可以给简洁提示;AttributeError、断言失败等未知缺陷不应被宽泛捕获后伪装成输入错误。捕获范围越小,调试线索越完整。稳定的 CLI 不是永不失败,而是失败类别清楚、旧数据仍可恢复、自动化调用能从退出码判断下一步。

还要验证帮助信息本身:python todo.py --help应列出三个子命令和--data,未知命令应由 argparse 返回非零退出码且不创建数据文件。命令行契约一旦被脚本调用,参数名与退出码就是公开接口;修改时要同步 README,并保留兼容迁移,而不是只让交互演示能跑。

十二、面试题与追问

  1. 为什么不用数据库?单人单文件足够,先把失败路径走完。追问:什么时候必须换存储?并发和查询变复杂时。
  2. 相对路径为什么危险?它相对 cwd 不是脚本。追问:__file__在 notebook 里可靠吗?不可靠,要注入数据目录。
  3. 坏 JSON 该崩溃还是当空?应崩溃或备份后提示,不能当空。追问:为什么?当空等于删数据。
  4. 如何测试 CLI?把 add/done/load/save 做成纯函数,测试不走 input。追问:要不要自动化按键盘?先测函数。
  5. id 用列表下标行不行?删除后错位。追问:自增 id 重启后怎么办?取 max+1。

十三、小蓝伞的工程金句

  • 写到哪里,不能取决于你站在哪里。
  • 静默把坏文件当成空列表,比崩溃更危险。
  • 能关终端再打开还在,才叫持久化。

十四、本篇技术清单与下一期

下一期AI-0132 网页电影信息抓取:把第 40 期 requests、第 37 期 JSON、本期文件保存和合规边界接起来。关注合集继续。你的 CLI 数据文件丢过吗?是 cwd 还是写到一半?先定文件契约,再谈功能列表。

官方资料

  • https://docs.python.org/zh-cn/3/library/json.html
  • https://docs.python.org/zh-cn/3/library/pathlib.html
  • https://docs.python.org/zh-cn/3/library/tempfile.html

适用边界

学习用单人工具。生产还要备份、权限和并发控制。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/24 17:30:20

基于微信小程序的母婴用品电商平台系统-附源码

温馨提示:本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系方式的名片! 温馨提示:本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系方式的名片! 温馨提示:本人主页置顶文章(点我)开头有 CSDN 平台…

作者头像 李华
网站建设 2026/9/24 17:29:45

基于微信小程序的二手书交易平台-附源码文档

温馨提示:本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系方式的名片! 温馨提示:本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系方式的名片! 温馨提示:本人主页置顶文章(点我)开头有 CSDN 平台…

作者头像 李华
网站建设 2026/9/24 17:28:43

按键控制LED

GPIO_ Read_ input_ Data_ bit 就是读取这里输入数据寄存器的某一位。 GPIO_ Read_ input_ Data 就是读取这整个输入数据寄存器。 GPIO_ Read_ output_ Data_ bit 就是读取这里输出数据寄存器的某一位。 GPIO_ Read_ output_ Data 就是读取这整个输出数据寄存器。 按键控制LED …

作者头像 李华
网站建设 2026/9/24 17:28:17

BA | 一周速学业务与系统框架

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/24 17:27:03

lu,震惊分析实验系统、震惊实验视频分析系统

震惊反射系统用于分析动物受到突发强刺激后的应激行为,单台计算机可管控 1‑5 个震惊反应箱。除噪声刺激外,还可叠加光、电、气流组合刺激,刺激间隔技术参数1、重量传感器量程:1‑2kg 2、系统架构:主控制器搭配装置控制…

作者头像 李华
网站建设 2026/9/24 17:26:48

springboot太原青年背包客深度游小程序20606-计算机课程设计、毕业设计

前言 ✨ 博主介绍:一线全栈工程师,毕设实战引路人。技术栈覆盖Java、Python、C#、PHP、Node.js及UniApp跨端开发,擅长多语言项目落地与架构设计。持续分享毕设源码、开题报告、技术选型心得与职场踩坑经验。用工程化思维写代码,帮…

作者头像 李华