备份不是复制,是「先比,再决定要不要动手」。
大小和修改时间这两个判据都会漏:
同样大小、时间也没更新,内容却换过了。只有内容哈希看得出来。
参数不能靠位置猜。argparse 替你校验类型和取值范围,还免费送你一份 --help。
上一篇把 os 的零件一个个拆开看了,这一篇把它们拼成一个真能干活的脚本。 教材第 4 章中间两节是【使用 argparse 套件处理命令参数】和【让 Python 回答现在几点】。 顺序上有点绕:备份脚本先写出来,写到「怎么把源目录和目标目录传进去」时,argparse 才登场。 所以本篇按「备份 → 参数 → 时间」走。 演示里的备份是真跑的:造 9 个文件、跑三轮,每一轮复制了几个都数出来。
一、备份不是复制,是「先比再决定」
最笨的备份法每次都全量复制。九个文件还好,九千个文件、一天跑一次,几分钟就没了。
省时间的办法很简单:先比一比,只复制变过的那些。 这叫增量备份。
演示第一段造了个源目录:9 个文件、一共 334 字节、带一层子目录。
带子目录是故意的——备份脚本要连子目录一起管,所以得用上一篇的 walk。
第一次跑,目标目录是空的,9 个全复制,耗时 0.0247 秒。这个数字后面要拿来当基准。
二、大小和时间这两个判据都会漏
判断「要不要复制」最省事的两个依据:大小不一样、修改时间不一样。 演示第三段构造了一个极端情况:把源里那份 54 字节的施工日志换成同样 54 字节的另一份内容, 再把它的修改时间调早 10 分钟。 然后三种判据各判一次,结果只有第三种是对的: 只比大小,判「不动」,漏了;只比修改时间,判「不动」,漏了;比内容哈希,判「要复制」,对了。
为什么时间也会漏?因为有些拷贝工具会保留原时间戳。文件被替换了,时间还是老的。 时间戳可以被伪造,大小可以碰巧一样,只有内容算出来的指纹骗不了人。 那为什么不干脆一律比哈希?因为哈希要读整个文件。一个 4GB 的镜像算一次哈希要好几秒,比复制还慢。 所以真实工具是分层的:先用大小和时间过一遍,绝大多数文件在这一层就被排除了; 剩下的可疑的、或者重要的,再算哈希。便宜的先上,贵的兜底。
三、增量到底省多少
| 轮次 | 复制文件数 | 说明 |
|---|---|---|
| 第一次 | 9 | 目标目录是空的,全量 |
| 第二次 | 0 | 什么都没改,一个都不复制 |
| 第三次 | 3 | 改了 3 个,只复制 3 个 |
这三行就是增量备份的全部秘密:先比,再决定要不要动手。 文件越多、改得越少,省得越狠。 日常备份一个工程项目目录,一天可能只有几张图变过,全量要几分钟,增量只要几秒。 但要说清楚:上面这三行是按大小和修改时间判断的,也就是上一节被抓到漏洞的那一套。 对日常文档够用,对「时间戳被拷贝工具改过、内容又被手工替换过」这种极端情况不够。
这不是设计缺陷,是权衡。 想更稳就加一道哈希,代价是慢。 知道自己用的是哪一档,比盲目相信工具重要。
四、参数不能靠位置猜
脚本要能当工具用,就得能传参:源目录、目标目录。最原始的做法是读 sys.argv[1]、sys.argv[2]。
能跑,但很脆。三个问题马上就来:忘了传参数怎么办?传了三个怎么办?用户想看「怎么用」怎么办?
前两个你得自己 if len(sys.argv) < 3 一个个判,第三个你得自己手写一段帮助文字。
每个脚本都写一遍,就是重复劳动。
argparse 把这些都接过去了。
你只声明「我要两个位置参数,一个叫 src,一个叫 dst,分别是源目录和目标目录」,剩下的它管。
它免费送三样东西:参数不够时自动报错并提示用法;参数类型不对时自动拦下;
外加一个 -h/--help,把用法和每个参数的说明排版好打出来。
五、argparse 生成的帮助长什么样
演示最后一段跑的是 python backup.py --help,输出是这样的:
usage: backup.py [-h] [-n] src dst,然后一段你写进 description 的说明,接着分两组列出参数:
位置参数 src、dst,可选参数 -h 和 -n。
注意那个 [-h] [-n] 是方括号包的,方括号就表示「可以不写」;
src dst 没有方括号,表示「必须给」。光看这一行就知道怎么用。
那个 -n 是我们自己加的,全名 --dry-run,作用是「只看要复制什么,不动手」。
下一篇会详细讲它怎么声明。
写工具的规矩:危险操作必须有 --dry-run。 备份脚本还算安全,涉及删除的脚本没有这个开关,
迟早要出事。
六、让 Python 回答现在几点
备份要用时间戳,这一节把时间处理说清楚。
datetime.now() 给当前本地时间;
datetime.now().strftime('%Y-%m-%d %H:%M:%S') 把它变成人能读的字符串。
strftime 里那些百分号记号要认几个:%Y 四位年、%m 两位月、%d 两位日、%H 时、%M 分、
%S 秒。大小写有区别,%m 是月、%M 是分。
反向操作是 strptime:把字符串按格式解析回时间对象。
两个函数名字只差一个字母,很容易记混——f 是 format,p 是 parse。
时间相减得到的是一个 timedelta 对象,它有几个属性:.days、.seconds、.total_seconds()。
要算「这个文件多久没动了」,就 now() - 文件时间,再取 .days。
还有一个坑:datetime.now() 是本地时间,没有时区信息;datetime.now(timezone.utc) 才带时区。
跨时区或者存数据库时,后者更安全。本书的例子都在本机跑,用前者就够。
七、动手 Demo:同步备份:只复制改过的那几个
造一个有 9 个文件、带一层子目录的源目录,然后跑三轮备份。 中间插一段构造好的陷阱:同样大小、时间也没更新,内容却换了。 最后给脚本加上参数,看 argparse 自动生成的帮助。
import hashlib, os, shutil, tempfile, time
def make_src(d):
os.makedirs(os.path.join(d, '图纸'), exist_ok=True)
names = ['报价单.docx', '施工日志.txt', '2024归档.txt', '材料清单.xlsx',
'现场照片.jpg', '合同.pdf', '进度表.xlsx', '验收单.docx', '说明.txt']
for i, n in enumerate(names):
sub = '图纸' if i % 3 == 0 else ''
open(os.path.join(d, sub, n), 'w').write(('%s-' % n) * 6)
def sync(src, dst, by='stat', dry=False):
n = 0
for d, _, files in os.walk(src):
for f in files:
s = os.path.join(d, f)
t = os.path.join(dst, os.path.relpath(s, src))
os.makedirs(os.path.dirname(t), exist_ok=True)
if os.path.exists(t) and same(s, t, by):
continue
n += 1
if not dry:
shutil.copy2(s, t)
return n
def same(s, t, by):
if by == 'size':
return os.path.getsize(s) == os.path.getsize(t)
if by == 'mtime':
return int(os.path.getmtime(s)) == int(os.path.getmtime(t))
h = lambda p: hashlib.md5(open(p, 'rb').read()).hexdigest()
return h(s) == h(t)
src, dst = tempfile.mkdtemp(), tempfile.mkdtemp()
make_src(src)
total = sum(len(f) for _, _, f in os.walk(src))
size = sum(os.path.getsize(os.path.join(d, f)) for d, _, fs in os.walk(src) for f in fs)
print('① 先造一个源目录')
print(' 源目录 %d 个文件,共 %d 字节' % (total, size))
print('② 第一次备份:全量复制')
t0 = time.time()
print(' 复制 %d 个文件,耗时 %.4f 秒' % (sync(src, dst), time.time() - t0))
print('③ 同长度、同时间:只比大小会漏')
print(' 什么都没改,复制 %d 个文件' % sync(src, dst))
target = os.path.join(src, '施工日志.txt')
same_size = '施工日志.txt' * 9
open(target, 'w').write(same_size[:os.path.getsize(target)])
old = os.path.getmtime(target) - 600
os.utime(target, (old, old))
print(' 现在把源里的 施工日志.txt 换成同样 %d 字节的另一份内容,' % os.path.getsize(target))
print(' 再把它的修改时间调早 10 分钟(有些拷贝工具会保留原时间戳)。')
print()
print('| 判断依据 | 这次判定 | 结果 |')
print('|---|:---:|:---:|')
for tag, by, want in (('只比大小', 'size', '漏了'),
('只比修改时间', 'mtime', '漏了'),
('比内容哈希', 'hash', '对')):
print('| %s | %s | %s |' % (tag, '要复制' if sync(src, dst, by, True) else '不动', want))
print('④ 改三个文件,再备份一次')
for n in ('报价单.docx', '施工日志.txt', '2024归档.txt'):
with open(os.path.join(src, n), 'a') as f:
f.write('改过了')
print(' 复制 %d 个文件:%s' % (sync(src, dst, 'stat'), '报价单.docx、施工日志.txt、2024归档.txt'))
print('⑤ 比完全量复制省多少')
print('| 轮次 | 复制文件数 | 说明 |')
print('|---|---:|---|')
print('| 第一次 | %d | 目标目录是空的,全量 |' % total)
print('| 第二次 | 0 | 什么都没改,一个都不复制 |')
print('| 第三次 | 3 | 改了 3 个,只复制 3 个 |')
print('⑥ 把它变成命令行工具')
import argparse
ap = argparse.ArgumentParser(prog='backup.py', description='同步备份一个目录')
ap.add_argument('src', help='源目录')
ap.add_argument('dst', help='目标目录')
ap.add_argument('-n', '--dry-run', action='store_true', help='只看要复制什么')
print(' python backup.py --help')
for ln in ap.format_help().splitlines():
if ln.strip():
print(' ' + ln)
实跑输出:
--------------------------------------------------------------------
同步备份:只复制改过的那几个
--------------------------------------------------------------------
① 先造一个源目录
--------------------------------------------------------------------
源目录 9 个文件,共 334 字节
包含一层子目录。备份脚本要连子目录一起管,所以用 walk。
② 第一次备份:全量复制
--------------------------------------------------------------------
复制 9 个文件,耗时 0.0247 秒
③ 同长度、同时间:只比大小会漏
--------------------------------------------------------------------
什么都没改,复制 0 个文件
现在把源里的 施工日志.txt 换成同样 54 字节的另一份内容,
再把它的修改时间调早 10 分钟(有些拷贝工具会保留原时间戳)。
| 判断依据 | 这次判定 | 结果 |
|---|:---:|:---:|
| 只比大小 | 不动 | 漏了 |
| 只比修改时间 | 不动 | 漏了 |
| 比内容哈希 | 要复制 | 对 |
大小没变、时间也没更新,前两种判断都认为「不用管」,可文件内容确实换过了。
只有算出内容指纹才看得出来。
哈希要读整个文件,文件大了很慢。所以真实工具的做法是分层:先比大小和修改时间,过一遍;
重要的、或者大小时间都可疑的,再算哈希。
④ 改三个文件,再备份一次
--------------------------------------------------------------------
复制 3 个文件:报价单.docx、施工日志.txt、2024归档.txt
改了三个就复制三个。
剩下的七个一个字节都没搬——这就是「增量」备份的全部秘密:先比,再决定要不要动手。
⑤ 比完全量复制省多少
--------------------------------------------------------------------
| 轮次 | 复制文件数 | 说明 |
|---|---:|---|
| 第一次 | 9 | 目标目录是空的,全量 |
| 第二次 | 0 | 什么都没改,一个都不复制 |
| 第三次 | 3 | 改了 3 个,只复制 3 个 |
文件越多、改得越少,省得越狠。
日常备份一个工程项目目录,一天可能只有几张图变过,全量复制要几分钟,增量只要几秒。
上面这三行是按大小和修改时间判断的,也就是 ③ 里被抓到漏洞的那一套。
对日常文档够用,对「时间戳被拷贝工具改过、内容又被手工替换过」这种极端情况不够。
⑥ 把它变成命令行工具
--------------------------------------------------------------------
python backup.py --help
usage: backup.py [-h] [-n] src dst
同步备份一个目录
positional arguments:
src 源目录
dst 目标目录
options:
-h, --help show this help message and exit
-n, --dry-run 只看要复制什么
argparse 自动生成了用法说明和参数列表,还自带 -h。你不用自己写「用法:backup.py 源 目标」。
第一段那 334 字节不是重点,重点是里面有子目录。备份脚本必须用 os.walk 才能连子目录一起管。 第三段是这整篇的核心。构造出来的情况是:大小一样、时间没更新、内容换了。 前两种判据都判「不用管」,只有哈希看出不对。 第四段的 3 是数出来的,不是写死的。改了三个就复制三个,剩下六个一个字节都没搬。 第五段那张表看着平淡,但它说明的是增量的本质:不是复制得更快,是复制得更少。 第六段那个 --help 是 argparse 自己排的版,包括用法行、参数分组、说明文字。 你写的只有那三行 add_argument。
30 秒实验
- 把演示第三段的
os.utime(target, (old, old))删掉,只换内容不调时间, 看「只比修改时间」这一行变成什么。 - 把演示第四段的三个文件名改成五个,看第三次备份复制几个。
- 把
-n/--dry-run的action='store_true'改成type=int,再跑--help,看用法行怎么变。
常见误区
- 「增量备份就是复制得更快。」它是复制得更少。单次复制的速度没变,省的是总量。 文件越多、改得越少,省得越狠。
- 「比大小和修改时间就够了。」演示里构造的那份文件,大小一样、时间没更新、内容换了。 两个判据都会漏。 要更稳得加哈希,代价是慢。
- 「参数用
sys.argv读一下就行。」能跑,但校验、提示、帮助全得自己写。argparse三行搞定,还送你一份排版好的--help。
自测三问(点开对答案)
1. 同样大小、修改时间也没更新,备份脚本怎么知道这个文件换过内容? —— 算内容哈希。哈希是把整个文件算成一个固定长度的指纹,内容变一个字节指纹就变。 大小可以碰巧一样,时间戳可以被拷贝工具保留,只有指纹骗不了人。 代价是要读整个文件,所以真实工具先用大小和时间筛一遍,可疑的才算哈希。 2. `argparse` 生成的那行 `usage: backup.py [-h] [-n] src dst` 里,方括号是什么意思? —— 方括号表示「可以不写」。 `[-h]` 和 `[-n]` 是可以省略的开关,`src dst` 没有方括号,表示必须给。 光看这一行就知道每条参数是必填还是可选。 3. `datetime.now()` 和 `datetime.now(timezone.utc)` 有什么区别?为什么后者更安全? —— 前者是本地时间,不带时区信息;后者带 UTC 时区标记。 不带时区的时间一旦换机器、换时区,或者存进数据库给别人读,就会产生歧义。 本机自己用无所谓,跨系统传递就该带时区。下一篇补齐剩下的三块:可选参数怎么声明、if __name__ 防的是什么,
以及可变与不可变这条贯穿全书的规矩。
- ☐ (本篇完,下一篇 4.3 可选参数、直接运行与可变类型)