操作文件和目录
文件读写关注“文件里面有什么”,文件和目录操作则关注“文件在哪里、叫什么、如何组织”。现代 Python 推荐使用 pathlib.Path 表示路径,再用 shutil 完成复制、移动和目录树操作。
from pathlib import Path
report = Path("data") / "reports" / "summary.txt"
print(report.parent) # data/reports
print(report.name) # summary.txt
本文示例适用于 Python 3.10 及以上版本。学完后,你应该能够:
- 使用
Path构造和拆分跨平台路径; - 查询文件或目录是否存在、类型和元数据;
- 创建、遍历、搜索、重命名和删除文件或目录;
- 使用
shutil复制、移动和删除目录树; - 处理常见异常,并降低误删、覆盖和路径越界的风险。
使用 Path 表示路径
Path 对象表示一个文件系统路径。用 / 运算符拼接路径,能够自动采用当前操作系统的路径规则:
from pathlib import Path
base_dir = Path("project")
config_path = base_dir / "config" / "settings.json"
print(config_path)
不要通过 "project/" + name 手动拼接路径。除了容易遗漏分隔符,也不利于跨平台运行。
相对路径与绝对路径
相对路径从当前工作目录开始解析:
from pathlib import Path
print(Path.cwd())
print(Path("data/report.txt").is_absolute()) # False
absolute() 可以得到绝对形式,resolve() 还会规范化 .、..,并通常解析符号链接:
from pathlib import Path
path = Path("data") / ".." / "output"
print(path.absolute())
print(path.resolve())
resolve() 默认使用 strict=False,路径尚未创建时也可以得到规范化结果;使用 resolve(strict=True) 则会在路径不存在时抛出 FileNotFoundError。是否应该解析符号链接取决于业务需求,不要只为了显示路径就随意改变它的含义。
用户主目录与环境变量
expanduser() 可以展开 ~:
from pathlib import Path
config_dir = Path("~/.myapp").expanduser()
Path 不会自动展开 $HOME、%APPDATA% 等环境变量。确实需要读取环境变量时,使用 os.environ 明确取得值:
import os
from pathlib import Path
cache_dir = Path(os.environ["MYAPP_CACHE_DIR"])
环境变量可能不存在,生产代码应根据配置要求提供默认值或清晰报错。
拆分和修改路径
路径可以在不访问文件系统的情况下拆分:
from pathlib import Path
path = Path("archive/reports/summary.tar.gz")
print(path.name) # summary.tar.gz
print(path.stem) # summary.tar
print(path.suffix) # .gz
print(path.suffixes) # ['.tar', '.gz']
print(path.parent) # archive/reports
print(path.parts) # ('archive', 'reports', 'summary.tar.gz')
常见修改方法会返回新对象,不会重命名磁盘上的文件:
from pathlib import Path
path = Path("reports/summary.txt")
print(path.with_name("detail.txt")) # reports/detail.txt
print(path.with_suffix(".csv")) # reports/summary.csv
print(path.parent / f"{path.stem}.bak")
Path 对象通常是不可变的。只有调用 rename()、unlink() 等文件系统方法时,磁盘内容才会改变。
查询路径状态
from pathlib import Path
path = Path("data/report.txt")
print(path.exists())
print(path.is_file())
print(path.is_dir())
print(path.is_symlink())
exists():路径指向的目标是否存在;is_file():是否为普通文件;is_dir():是否为目录;is_symlink():路径本身是否为符号链接。
状态可能在检查后立即变化。例如,exists() 返回 True 后,另一个进程可能马上删除文件。因此,预检查可以改善提示,但真正操作时仍要处理异常。
读取元数据
stat() 返回文件大小、时间和权限等元数据:
from datetime import datetime
from pathlib import Path
path = Path("data/report.txt")
info = path.stat()
print(info.st_size) # 字节数
print(datetime.fromtimestamp(info.st_mtime)) # 最后修改时间
常见属性包括:
| 属性 | 含义 |
|---|---|
st_size | 文件大小,单位为字节 |
st_mtime | 最后修改时间的时间戳 |
st_atime | 最后访问时间的时间戳 |
st_mode | 文件类型与权限位 |
时间语义和精度由操作系统及文件系统决定。不要假设所有平台都能可靠记录完全相同的创建时间或访问时间。
创建文件和目录
创建目录
from pathlib import Path
output_dir = Path("output/reports")
output_dir.mkdir(parents=True, exist_ok=True)
parents=True:父目录不存在时一并创建;exist_ok=True:目标目录已经存在时不报错。
如果同名路径存在但它是文件,即使设置 exist_ok=True 也会抛出 FileExistsError。
创建空文件
from pathlib import Path
path = Path("output/ready.flag")
path.parent.mkdir(parents=True, exist_ok=True)
path.touch(exist_ok=True)
touch() 在文件不存在时创建空文件;文件已经存在时,默认会更新其修改时间。若不允许覆盖已有路径,使用 touch(exist_ok=False)。
写入文本或字节也能自动创建文件,但不会自动创建父目录:
from pathlib import Path
path = Path("output/message.txt")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("任务完成\n", encoding="utf-8")
查看和遍历目录
遍历直接子项
iterdir() 返回目录中的直接子项,顺序由文件系统决定:
from pathlib import Path
directory = Path("data")
for child in sorted(directory.iterdir(), key=lambda item: item.name):
kind = "目录" if child.is_dir() else "文件"
print(kind, child.name)
需要稳定顺序时应显式排序。文件名的大小写规则和排序效果可能因操作系统而异。
使用通配模式搜索
glob() 按模式搜索,rglob() 递归搜索子目录:
from pathlib import Path
docs_dir = Path("docs")
for markdown_file in docs_dir.glob("*.md"):
print(markdown_file)
for python_file in docs_dir.rglob("*.py"):
print(python_file)
常见通配符:
| 模式 | 含义 |
|---|---|
* | 匹配任意数量的字符 |
? | 匹配一个字符 |
** | 跨目录递归匹配 |
也可以组合筛选条件:
from pathlib import Path
large_logs = [
path
for path in Path("logs").rglob("*.log")
if path.is_file() and path.stat().st_size > 1024 * 1024
]
大型目录树中反复调用 stat() 可能较慢,应根据实际需求限制搜索范围。
重命名和移动
rename() 与 replace()
rename() 可以在同一文件系统内重命名或移动路径:
from pathlib import Path
source = Path("draft.txt")
target = Path("archive/final.txt")
target.parent.mkdir(parents=True, exist_ok=True)
source.rename(target)
目标已经存在时,rename() 的行为可能因操作系统而异。需要明确替换目标时使用 replace():
source.replace(target)
即使使用 replace(),替换目录、跨文件系统移动和被其他程序占用的文件也可能失败,应处理 OSError。如果不允许覆盖,应在业务层采用独占创建或其他并发控制;仅先调用 exists() 仍存在竞态条件。
使用 shutil.move()
shutil.move() 可以移动文件或整个目录,并能在跨文件系统时退化为复制后删除:
from pathlib import Path
import shutil
source = Path("downloads/report.csv")
target_dir = Path("archive/2026")
target_dir.mkdir(parents=True, exist_ok=True)
new_path = shutil.move(source, target_dir / source.name)
print(new_path)
跨文件系统的移动不再是单一步骤,中途失败时需要检查源和目标状态。
复制文件和目录
shutil 提供高层复制操作:
from pathlib import Path
import shutil
source = Path("data/report.txt")
backup = Path("backup/report.txt")
backup.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, backup)
| 函数 | 用途 |
|---|---|
shutil.copyfile() | 只复制文件内容,源和目标都必须是文件路径 |
shutil.copy() | 复制内容和权限模式 |
shutil.copy2() | 尽可能再保留修改时间等元数据 |
shutil.copytree() | 递归复制整个目录树 |
没有一种跨平台复制能够保证保留所有所有者、ACL、扩展属性等元数据。对备份、部署等严格场景,应确认目标平台的实际要求。
复制目录树:
from pathlib import Path
import shutil
source_dir = Path("project-template")
target_dir = Path("workspace/my-project")
shutil.copytree(source_dir, target_dir)
目标目录默认必须不存在。Python 3.8 及以上版本可以设置 dirs_exist_ok=True,把内容复制到已经存在的目录中:
shutil.copytree(source_dir, target_dir, dirs_exist_ok=True)
这会合并目录并覆盖同名文件,但不会删除目标中多余的旧文件,因此它不等同于“完全同步”。
删除文件和目录
删除文件或符号链接
from pathlib import Path
path = Path("output/old.txt")
path.unlink(missing_ok=True)
missing_ok=True 在 Python 3.8 及以上版本可用,路径不存在时不会报错。unlink() 删除符号链接本身,不会递归删除它指向的目录。
删除空目录
from pathlib import Path
Path("output/empty").rmdir()
目录非空时,rmdir() 会抛出 OSError。
删除整个目录树
from pathlib import Path
import shutil
target = Path("build-cache")
if target.is_dir():
shutil.rmtree(target)
rmtree() 会递归删除目录中的全部内容,通常难以恢复。删除前至少应做到:
- 目标来自可信配置,而不是未经检查的用户输入;
- 把路径规范化后确认它位于允许删除的根目录中;
- 拒绝空路径、文件系统根目录和过于宽泛的目录;
- 重要数据先备份,必要时使用回收站方案而不是永久删除。
路径边界与安全
Web 服务经常根据用户提供的相对路径访问固定目录。只做字符串前缀检查并不可靠,例如 /srv/data-old 也以 /srv/data 开头。应解析路径后检查父子关系:
from pathlib import Path
def resolve_inside(base_dir: Path, user_path: str) -> Path:
base = base_dir.resolve()
candidate = (base / user_path).resolve()
if not candidate.is_relative_to(base):
raise ValueError("路径超出允许目录")
return candidate
Path.is_relative_to() 从 Python 3.9 开始可用。这个检查可以阻止普通的 .. 路径穿越,但安全敏感程序还要考虑符号链接在检查后被替换的竞态条件,并使用操作系统提供的更严格文件描述符接口或隔离环境。
文件权限
chmod() 可以修改权限模式:
from pathlib import Path
script = Path("run.sh")
script.chmod(0o755)
权限模型高度依赖操作系统。Unix 风格的 0o755 在 Windows 上不具有完全相同的意义,文件所有者、ACL 和继承权限也不由这一行代码完整表达。跨平台应用应尽量只设置确实需要的权限,并处理 PermissionError。
使用临时文件和目录
不要自己猜测临时目录或用固定名称。tempfile 会选择合适位置并避免常见命名冲突:
from pathlib import Path
from tempfile import TemporaryDirectory
with TemporaryDirectory() as temporary_dir:
workspace = Path(temporary_dir)
result_path = workspace / "result.txt"
result_path.write_text("临时结果", encoding="utf-8")
print(result_path.read_text(encoding="utf-8"))
# 离开 with 后,临时目录及其内容被删除
如果外部程序需要通过路径访问临时文件,可以使用 NamedTemporaryFile。Windows 对仍打开文件的再次打开规则与 Unix 不同,跨平台代码应根据 delete、delete_on_close 和关闭时机进行设计,并以所支持的 Python 版本文档为准。
常见异常
| 异常 | 常见原因 |
|---|---|
FileNotFoundError | 源文件、目录或某一级父路径不存在 |
FileExistsError | 要创建的路径已经存在 |
PermissionError | 当前进程没有读取、写入或删除权限 |
NotADirectoryError | 路径中本应是目录的部分实际是文件 |
IsADirectoryError | 把目录当作普通文件操作 |
OSError | 文件占用、跨设备限制等系统错误 |
只捕获能够处理的异常,并保留原始异常信息:
from pathlib import Path
import shutil
def backup_file(source: Path, backup_dir: Path) -> Path:
backup_dir.mkdir(parents=True, exist_ok=True)
target = backup_dir / source.name
return Path(shutil.copy2(source, target))
try:
backup = backup_file(Path("data/report.csv"), Path("backup"))
except FileNotFoundError as error:
print(f"找不到文件:{error.filename}")
except PermissionError as error:
print(f"没有操作权限:{error.filename}")
else:
print(f"备份完成:{backup}")
不要使用 except OSError: pass 静默忽略所有问题,否则复制失败、磁盘空间不足等重要错误也会被隐藏。
综合示例:归档日志
下面的函数查找指定天数以前的 .log 文件,并将它们移动到归档目录。dry_run=True 时只显示计划,不实际移动:
from datetime import datetime, timedelta
from pathlib import Path
import shutil
def archive_old_logs(
log_dir: Path,
archive_dir: Path,
days: int,
*,
dry_run: bool = True,
) -> list[Path]:
if days < 0:
raise ValueError("days 不能为负数")
cutoff = datetime.now().timestamp() - timedelta(days=days).total_seconds()
archive_dir.mkdir(parents=True, exist_ok=True)
archived: list[Path] = []
for source in sorted(log_dir.glob("*.log")):
if not source.is_file() or source.stat().st_mtime >= cutoff:
continue
target = archive_dir / source.name
if target.exists():
raise FileExistsError(f"归档文件已存在:{target}")
print(f"{source} -> {target}")
if not dry_run:
shutil.move(source, target)
archived.append(target)
return archived
archive_old_logs(
Path("logs"),
Path("archive/logs"),
days=30,
dry_run=True,
)
实际归档系统还应考虑并发写入、重名策略、时区、磁盘空间和失败恢复。对可能批量修改文件的工具,先提供预览模式通常更安全。
常见错误与最佳实践
- 路径使用
Path构造,不手动拼接分隔符; - 相对路径取决于当前工作目录,应明确基准目录;
iterdir()和glob()的结果顺序不保证稳定,需要时显式排序;- 状态检查不能代替异常处理,因为文件系统随时可能变化;
- 复制、移动前明确同名目标的覆盖策略;
- 递归删除前校验规范化后的精确路径,并为批量操作提供预览;
- 只捕获能够恢复或转换为清晰业务提示的异常。
小结
- 使用
pathlib.Path表示、拼接、拆分和查询路径; mkdir()、touch()、rename()、replace()、unlink()和rmdir()处理基础操作;iterdir()查看直接子项,glob()和rglob()按模式搜索;shutil负责复制、跨文件系统移动和目录树操作;- 文件系统存在并发变化、平台差异和权限限制,预检查之后仍要处理异常;
- 覆盖和递归删除属于高风险操作,必须明确目标、边界和恢复策略。