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

程序要动硬盘,第一件事是搞清楚「我现在在哪」。os.getcwd() 回答这个。 路径不能用加号拼。 Windows 两种斜杠都认,所以这个坑在这里看着不严重; 换到 Linux 上,一个反斜杠就能让整段路径失效。 os.walk 一次给你一层,外层 for 加内层 for 就遍历完了,不用自己写递归。

📝 本章和前面几章的关系

第 3 章讲的都是内存里的东西,程序一关就没了。这一章开始动硬盘。 教材第 4 章开头两节是【同步备份文件】和【使用 os 程序库操作文件】。 教材把「要做什么」写在前面、「用什么做」写在后面。 本篇照这个顺序:先看 os 能干什么,下一篇再把它们拼成那个备份脚本。 有一件事必须提前警告:shutil.rmtree 没有回收站。 本篇最后一节会讲,看到那里请慢一点。

一、先搞清楚「当前在哪」

os.getcwd() 告诉你当前目录。 所有相对路径都相对它算,所以百分之八十的「文件找不到」,答案都在这一行。 os.listdir(d) 列出目录里的名字。它有两个局限:只给名字不给完整路径,而且不区分文件和文件夹。 想区分,就再判断一次:os.path.isdir(名字) 是文件夹,os.path.isfile(名字) 是文件。 或者干脆用 os.scandir(),它给的对象自带 is_dir() 方法。

演示里那个目录只有一个 a.txt 和一个 sub。 listdir 返回 ['a.txt', 'sub'],光看这两个字符串,分不出哪个是文件夹。

二、路径不能用加号拼

演示第二段把两种拼法摆在一起看:加号拼 得到 …/m01/sub,os.path.join 得到 …\m01\sub。 两个字符串不相等:一个正斜杠,一个反斜杠。但两个都能打开——因为 Windows 两种都认。 这就是这个坑在这里看不太出来的原因。 换到 Linux 上,反斜杠不是分隔符, 整个路径会被当成一个奇怪的文件名。

真正会出事的还有两种情况。一是源目录末尾本来就带斜杠,src + "/" + 名字 拼出来变成两层斜杠; 二是路径里有中文或空格,拼错一个字符整段失效。 os.path.join 会自己处理分隔符,而且能一次接好几个参数:join(a, b, c)。永远用它,别用加号。

三、拆路径的四个零件

函数 给你什么 例
os.path.dirname(p) 去掉最后一段的目录部分 …\sub\deep
os.path.basename(p) 最后那一段 c.txt
os.path.splitext(p) 名字和扩展名分开 ('c', '.txt')
os.path.exists(p) 在不在 True

splitext 分的是「最后一个点」。文件名里带点也没关系,它只认最后那个。 所以 splitext('报告.2024.最终版.docx') 给的是 ('报告.2024.最终版', '.docx'), 不会在中间那个点上断。 判断文件类型别自己切字符串,用 splitext。 它处理了那些你想不到的情况,包括根本没有扩展名(返回第二段是空字符串)。

四、走一遍整棵树

os.walk(根目录) 一次给你三样:当前目录路径、子目录列表、文件列表。然后自动往下一层走,直到走完。 演示里那棵树有三层,walk 给出三次结果:根目录、sub、sub\deep。 注意它是自上而下,一层一层来的。 写法固定是两层 for:外层拿三个变量,内层走文件列表。 这就是「遍历一个文件夹里所有文件」的全部代码,不用自己写递归。

备份脚本全靠这个。想「所有文件」就两层 for 收完; 想跳过某个子目录,在遍历时把 dirs 里那个名字删掉就行,walk 就不会往下走它了。

五、大小、时间、权限

os.path.getsize(p) 给字节数。 os.path.getmtime(p) 给修改时间,但它是一个时间戳——从 1970 年 1 月 1 日算起的秒数, 直接打印是个十几亿的整数。 要给人看,得转一下:datetime.fromtimestamp(mtime)。下一篇会专门讲时间怎么处理。 这个时间戳是下一篇备份脚本的核心判据。 「谁新谁旧」全靠它比。

权限用 os.stat(p).st_mode 拿,但那是一串带类型信息的数字(显示成 100666 这样)。 要纯权限位,套一层 stat.S_IMODE(),得到 666。 666 是八进制,意思是不管谁都能读能写。 演示里这个值是 666,因为 Windows 上权限模型跟 Unix 不一样,Python 只能给个象征性的值。 真要管权限,得在 Linux 上做。

六、建、改、删,以及那条不能撤销的命令

os.makedirs(p, exist_ok=True) 建目录,会一路建上去。os.mkdir 只能建一层,父目录不存在就报错。 exist_ok=True 这个参数很关键。 不加它,目录已经存在时会抛 FileExistsError。 后果是:同一个脚本第二次跑就崩在「文件已存在」上。 改名用 os.rename(旧, 新),删文件用 os.remove(p),删空目录用 os.rmdir(p)。

要连内容一起删目录,是 shutil.rmtree(p)。这条命令没有回收站,删了就是删了。 写自动化脚本时,这条命令是唯一一个「跑错了没法撤回」的地方。 本书的做法是:涉及 rmtree 的脚本,先把要删的路径打印出来,加一个 --dry-run 只打印不动手, 确认过再真跑。下一篇的备份脚本就是这个套路。

七、动手 Demo:os:文件夹和路径

造一棵三层的目录树,然后从「我在哪」一路试到「怎么删干净」。 重点看两处:加号拼出来的路径和 os.path.join 拼出来的那个 False, 还有 os.walk 一层一层吐出什么。

import os, stat, shutil, tempfile
from datetime import datetime

root = tempfile.mkdtemp()
os.makedirs(os.path.join(root, 'sub', 'deep'))
for p in ('a.txt', 'sub/b.txt', 'sub/deep/c.txt'):
    open(os.path.join(root, p), 'w').write('x')

print('① 当前在哪、里面有什么')
print('  os.getcwd()      =', os.getcwd())
print('  os.listdir(root) =', os.listdir(root))

print('② 路径为什么不能用加号拼')
m01 = os.path.join(root, 'sub')
print('  加号拼  ', m01 + '/deep')
print('  join    ', os.path.join(m01, 'deep'))
print('  两者字符串相等?', (m01 + '/deep') == os.path.join(m01, 'deep'))
print('  os.sep  =', repr(os.sep))

print('③ 拆路径的四个零件')
p = os.path.join(root, 'sub', 'deep', 'c.txt')
print('  完整路径 ', p)
print('  dirname  ', os.path.dirname(p))
print('  basename ', os.path.basename(p))
print('  splitext ', os.path.splitext(p))
print('  exists   ', os.path.exists(p))

print('④ 走一遍整棵树')
print('  os.walk 给出的三样:目录路径、子目录列表、文件列表')
for d, dirs, files in os.walk(root):
    print('    %-24s 子目录 %s 文件 %s'
          % (os.path.relpath(d, root), dirs, files))

print('⑤ 文件的大小和时间')
f = os.path.join(root, 'a.txt')
st = os.stat(f)
print('  getsize   %d 字节' % os.path.getsize(f))
print('  getmtime  %s' % datetime.fromtimestamp(st.st_mtime).strftime('%Y-%m-%d %H:%M:%S'))
print('  权限位    %o(八进制,666 就是所有人可读可写)' % stat.S_IMODE(st.st_mode))

print('⑥ 建、改、删')
newd = os.path.join(root, 'backup')
os.makedirs(newd, exist_ok=True)
os.makedirs(newd, exist_ok=True)
print('  makedirs(exist_ok=True) 建成 True(跑两次都不报错)')
os.rename(f, os.path.join(root, 'a_renamed.txt'))
print('  rename → a.txt 还在?%s  a_renamed 在?%s'
      % (os.path.exists(f), os.path.exists(os.path.join(root, 'a_renamed.txt'))))
os.remove(os.path.join(root, 'a_renamed.txt'))
print('  remove 之后 a_renamed 在?%s' % os.path.exists(os.path.join(root, 'a_renamed.txt')))
shutil.rmtree(root)
print('  rmtree 之后 root 在?%s  ← 这条没有回收站' % os.path.exists(root))

实跑输出:

--------------------------------------------------------------------
os:文件夹和路径
--------------------------------------------------------------------

  ① 当前在哪、里面有什么
  --------------------------------------------------------------------
    os.getcwd()      = I:\Desktop\自我教学\.workbuddy\scripts\ch58_dev
    os.listdir(d)    = ['a.txt', 'sub']
  listdir 只给名字,不给完整路径,而且不区分文件和文件夹。想区分就得再判断一次。

  ② 路径为什么不能用加号拼
  --------------------------------------------------------------------
    加号拼  ts\ch58_dev\_tmp58\m01/sub
    join    ts\ch58_dev\_tmp58\m01\sub
    两者字符串相等?False
    os.sep  = '\\'
  这次两个路径都能打开,但字符串并不相等:一个用正斜杠,一个用反斜杠。
  Windows 两种都认,所以这个坑在这里看不太出来。
  真正会出事的是三种情况:源目录末尾本来就带斜杠,拼出来变成两层;换到 Linux 上反斜杠不是分隔符;
  路径里有中文或空格时拼接顺序一错就整段失效。join 会自己处理分隔符,也能接多个参数,永远用它。

  ③ 拆路径的四个零件
  --------------------------------------------------------------------
    完整路径  scripts\ch58_dev\_tmp58\m01\sub\deep\c.txt
    dirname   pts\ch58_dev\_tmp58\m01\sub\deep
    basename  c.txt
    splitext  ('c', '.txt')
    exists    True
  splitext 分的是「最后那个点」,返回 (名字, 扩展名)。文件名里带点也没关系,它只认最后一个。

  ④ 走一遍整棵树
  --------------------------------------------------------------------
    os.walk 给出的三样:目录路径、子目录列表、文件列表
      .            子目录 ['sub']  文件 ['a.txt']
      sub          子目录 ['deep'] 文件 ['b.txt']
      sub\deep     子目录 []       文件 ['c.txt']
  walk 是自上而下递归,一次给你一层。
  要统计「这个文件夹里所有文件」,外层 for 加上内层 for 就完了,不用自己写递归。

  ⑤ 文件的大小和时间
  --------------------------------------------------------------------
    getsize   1 字节
    getmtime  2026-11-02 02:46:49
    权限位    666(八进制,666 就是所有人可读可写)
  修改时间是个时间戳(从 1970 年起的秒数),要给人看就得 fromtimestamp 转一下。
  下一节的备份脚本全靠这个数判断新旧。

  ⑥ 建、改、删
  --------------------------------------------------------------------
    makedirs(exist_ok=True) 建成 True
    rename → a.txt 还在?False  a_renamed 在?True
    remove 之后 a_renamed 在?False
  exist_ok=True 很关键:目录已经存在时不报错。
  不加这个参数,第二次跑同一个脚本就会崩在「文件已存在」上。
  makedirs 会一路建上去,mkdir 只能建一层。
  删目录用 rmdir(只能删空目录),要连内容一起删是 shutil.rmtree——这条命令没有回收站,
  用之前想清楚。

第一段那个 listdir 只给了两个名字。光看字符串,分不出 a.txt 是文件、sub 是目录,得再判断一次。 第二段是本篇最重要的一处。两个路径都能打开,但字符串不相等:一个正斜杠一个反斜杠。 Windows 两种都认,所以这儿看不太出来,换到 Linux 就出事。 第三段 splitext 分的是最后一个点。文件名里带点它不管,只认最后那个。 第四段那三层结果要竖着看:每一行都是「一层」。 walk 是自上而下递归,一次给你一层,外层 for 加内层 for 就走完了。 第五段那个 666 是八进制,Windows 上只是象征性的值。真要管权限得在 Linux 上做。 第六段两次 makedirs 都不报错,靠的是 exist_ok=True。 最后那句 rmtree 是本篇唯一不可撤销的操作,写脚本时一定要加 dry-run 开关。

30 秒实验

  1. 把演示第二段的 m01 + '/deep' 改成 m01 + os.sep + 'deep',再看两个字符串相不相等。
  2. 把 os.walk(root) 改成 os.walk(root, topdown=False),看三层的顺序变成什么样。
  3. 在演示第五段加一行 print(os.stat(f).st_mode),跟 stat.S_IMODE 的结果比一比, 看多出来的那几位是什么。

常见误区

  1. 「路径用加号拼一下就行。」Windows 碰巧认,Linux 不认。 os.path.join 会自己处理分隔符,而且能接多个参数。 永远用它。
  2. 「listdir 能分出文件和文件夹。」它只给名字。 要区分就再判断一次,用 os.path.isdir 或直接换 os.scandir。
  3. 「删目录就是 shutil.rmtree。」它没有回收站。 这是自动化脚本里唯一一个跑错没法撤回的操作。先打印路径、加 --dry-run 确认,再真删。
自测三问(点开对答案) 1. `'…/m01' + '/deep'` 和 `os.path.join('…/m01', 'deep')`,两个路径都能打开,为什么还要用后者? —— 因为能打开只是 Windows 宽容,字符串本身已经错了:一个正斜杠一个反斜杠。 换到 Linux 上反斜杠不是分隔符,整段会失效;另外源目录末尾带斜杠时,加号会拼出两层斜杠。 `join` 把这些都处理了。 2. `os.makedirs(p, exist_ok=True)` 里那个 `exist_ok` 不加会怎样? —— 目录已经存在时会抛 `FileExistsError`。后果是同一个脚本第二次跑就崩。 加了它,存在就跳过,脚本可以反复跑——这一点在定时任务里是必须的。 3. `os.path.getmtime` 打出来是一串十几亿的整数,那是什么? —— 时间戳:从 1970 年 1 月 1 日算起过了多少秒。 要给人看得用 `datetime.fromtimestamp()` 转成本地时间。下一篇的备份脚本就靠比这个数判断新旧。

下一篇把这些零件拼起来:一个只复制改过文件的备份脚本,再加上命令行参数。

相关笔记