🐍 第4章 操作文件夹与文件:同步备份文件
📄 一句话概括

备份不是复制,是「先比,再决定要不要动手」。 大小和修改时间这两个判据都会漏: 同样大小、时间也没更新,内容却换过了。只有内容哈希看得出来。 参数不能靠位置猜。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 秒实验

  1. 把演示第三段的 os.utime(target, (old, old)) 删掉,只换内容不调时间, 看「只比修改时间」这一行变成什么。
  2. 把演示第四段的三个文件名改成五个,看第三次备份复制几个。
  3. 把 -n/--dry-run 的 action='store_true' 改成 type=int,再跑 --help,看用法行怎么变。

常见误区

  1. 「增量备份就是复制得更快。」它是复制得更少。单次复制的速度没变,省的是总量。 文件越多、改得越少,省得越狠。
  2. 「比大小和修改时间就够了。」演示里构造的那份文件,大小一样、时间没更新、内容换了。 两个判据都会漏。 要更稳得加哈希,代价是慢。
  3. 「参数用 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__ 防的是什么, 以及可变与不可变这条贯穿全书的规矩。

相关笔记