适用场景与现象
一个 CSV 导入任务用原始文件名去掉扩展名作为批次编号。普通测试文件 report.csv 看起来正常,上线后 sales.csv 却变成了 ale,status.csv 变成了 tatu。如果再把结果用于去重键或归档名称,就可能关联错批次,甚至覆盖已有输出。
本文用纯字符串复现故障,不执行任何文件重命名。适用于 Python 3.9 及以上;修复的业务约定是:只接收单个文件名、精确匹配小写 .csv、只去掉末尾一次扩展名、不接受空主体。
先把文件操作从排查中拿掉
在 Python 交互环境执行:
for name in ("report.csv", "sales.csv", "status.csv", "csv.csv"):
print(repr(name), repr(name.strip(".csv")), repr(name.rstrip(".csv")))
预期结果:
'report.csv' 'report' 'report'
'sales.csv' 'ale' 'sale'
'status.csv' 'tatu' 'statu'
'csv.csv' '' ''
这一步已经足以排除编码、文件系统和 CSV 内容:原始字符串正确,错误出现在生成批次编号时。排查时应比较入口名称和派生编号,而不只是检查文件是否存在。真实名称可能含业务信息,生产日志需按权限控制或脱敏;这里的名称均为合成示例。
根因:参数不是一个待删除的词
strip(".csv") 把参数看成四个字符的集合:点、c、s、v。它从两端持续删除属于这个集合的字符,直到遇到集合外的字符。rstrip 只从右端处理,但依然使用字符集合语义。
因此,把 strip 改成 rstrip 仍然会把 sales.csv 处理成 sale。调整参数字符顺序也没有作用。使用 replace(".csv", "") 则会删除名称中间的匹配片段,不能表达“只删除末尾一次”。
Python 官方文档将这些方法区分为按字符集合裁剪与按完整后缀删除,详见 字符串方法说明 和 removesuffix。
修复:先定义接收范围,再精确移除后缀
将下面代码保存为 batch_name.py:
def csv_batch_name(filename: str) -> str:
"""从单个 CSV 文件名提取批次编号,保留主体原有字符。"""
if not isinstance(filename, str):
raise TypeError("文件名必须是字符串")
if not filename or len(filename) > 255:
raise ValueError("文件名长度必须在 1 到 255 个字符之间")
if any(character in filename for character in ("/", "\\", ":", "\x00")):
raise ValueError("只能传入单个文件名,不能包含路径或空字符")
if any(ord(character) < 32 for character in filename):
raise ValueError("文件名不能包含控制字符")
if not filename.endswith(".csv"):
raise ValueError("文件名必须以小写 .csv 结尾")
batch_name = filename.removesuffix(".csv")
if not batch_name or not batch_name.strip():
raise ValueError("批次编号不能为空")
return batch_name
这里的 endswith 是输入校验:缺少后缀时直接拒绝。removesuffix 则是转换:只移除末尾的一次完整匹配;不存在匹配时它原样返回,所以不能单独承担校验职责。
最后一次无参数 strip() 仅用于判断主体是否全为空白,返回值仍是未经裁剪的主体。比如 sales.csv 返回 sales,不会偷偷改变业务编号。若业务禁止前后空格,应显式拒绝,而不是隐式归一化。
长度 255 是此示例的字符数策略,不代表所有文件系统都允许该长度。真实落盘时还要考虑字节长度、Windows 保留名称和尾部空格等平台规则;此函数也不负责路径授权或文件内容检查。
回归验证:不要只测 report.csv
把下面代码保存为同目录的 test_batch_name.py:
import unittest
from batch_name import csv_batch_name
class BatchNameTests(unittest.TestCase):
"""覆盖字符边界、后缀策略和非法输入。"""
def test_preserves_boundary_characters(self) -> None:
for filename, expected in (
("report.csv", "report"),
("sales.csv", "sales"),
("status.csv", "status"),
("csv.csv", "csv"),
("库存.csv", "库存"),
(" sales.csv", " sales"),
):
with self.subTest(filename=filename):
self.assertEqual(csv_batch_name(filename), expected)
def test_removes_only_last_suffix(self) -> None:
self.assertEqual(csv_batch_name("sales.csv.csv"), "sales.csv")
def test_rejects_wrong_suffix(self) -> None:
for filename in ("sales", "sales.CSV", "sales.csv.bak", ""):
with self.subTest(filename=filename):
with self.assertRaises(ValueError):
csv_batch_name(filename)
def test_rejects_empty_batch(self) -> None:
for filename in (".csv", " .csv"):
with self.subTest(filename=filename):
with self.assertRaises(ValueError):
csv_batch_name(filename)
def test_rejects_path_and_control_characters(self) -> None:
for filename in (
"../sales.csv", "dir/sales.csv", "dir\\sales.csv",
"C:sales.csv", "bad\x00.csv", "bad\n.csv",
):
with self.subTest(filename=filename):
with self.assertRaises(ValueError):
csv_batch_name(filename)
def test_length_boundary(self) -> None:
self.assertEqual(csv_batch_name("a" * 251 + ".csv"), "a" * 251)
with self.assertRaises(ValueError):
csv_batch_name("a" * 252 + ".csv")
def test_rejects_non_string(self) -> None:
with self.assertRaises(TypeError):
csv_batch_name(None)
if __name__ == "__main__":
unittest.main()
在这两个文件所在目录执行:
python -m unittest -v test_batch_name
应执行 7 项测试并全部通过。最关键的断言是 sales.csv → sales 和 csv.csv → csv:它们把合法主体尾端的 s、c、v 纳入测试,而不是只验证一个碰巧不触发缺陷的名称。
还应验证测试确实能抓到旧问题:临时将实现中的 filename.removesuffix(".csv") 改成 filename.rstrip(".csv"),重新执行测试,应出现断言失败;恢复后再次运行应全部通过。这个检查无需接触真实目录或历史文件。
已产生错误名称时怎么处理
先暂停依赖错误编号的重命名、覆盖和去重操作,保留原始导入清单。利用原始文件名重新计算批次编号,并与数据库记录和归档清单逐一比对。
不能从截断后的编号可靠地还原原名:不同名称可能映射为相同结果。没有原始清单或备份时,应人工核对,不能用“补回一个 s”的规则批量恢复。真正迁移时先生成旧名称、新名称、关联记录和冲突列表,再经过核对执行;本文没有实现迁移工具,也没有验证磁盘覆盖保护或数据库唯一性约束。
预防与验证边界
代码审查时,把 strip 的参数读成“可被删除的字符集合”,而不是“前后缀”。明确的单次前后缀处理使用 removeprefix、removesuffix;涉及实际路径结构时另行使用路径对象,并确认多重扩展名的业务规则。
本例测试只验证字符串转换和输入边界,不证明导入任务具有幂等性,也不覆盖实际文件系统、并发归档、权限和数据库关联。大小写策略同样是业务决定;如果要允许 .CSV,应增加对应规则和测试,保留主体的原始大小写。
这类问题通常没有异常栈。修复的关键是先把字符串转换单独复现,再用能触发字符集合裁剪的边界名称锁定行为,最后检查历史错误输出,而不是只替换一个方法名后继续运行。
Discussion
评论