一. 前言:檔案整理不是 cp -r 打到手抽筋
#
很多 Python 自動化工具做到最後,都會碰到同一類問題:
- 下載一批報表後,要搬到固定資料夾
- 產生結果後,要複製成發佈版本
- 收到使用者上傳的檔案後,要整理、備份、壓縮
- CI 跑完後,要把 artifacts 打包
- 清資料夾前,想先確認空間和安全邊界
這些事情當然可以用 shell script 做。
但如果主流程已經在 Python 裡,硬拆去 shell,常常會變成平台差異、路徑 quoting、工作目錄和錯誤處理問題。
這時候
shutil就很好用。shutil是 Python 標準庫裡負責「高階檔案操作」的模組。 如果pathlib是幫你優雅描述路徑,tempfile是幫你安全建立暫存檔,那shutil就是幫你真的把檔案搬來搬去、整包複製、壓縮、清理和檢查空間。 今天拍拍君會帶你做一個務實版本: - 複製單一檔案
- 複製整個目錄
- 安全搬移資料
- 建立 zip archive
- 寫一個批次整理工具
- 避開最常見的危險操作 檔案系統很可愛,但它也很會咬人。請先把備份準備好。
二. 安裝:標準庫內建,先建一個小專案 #
shutil 是標準庫,不需要安裝。
但這篇會搭配 pathlib,因為路徑用 Path 寫起來比較清楚。
用 uv 建一個乾淨練習環境:
uv init shutil-lab
cd shutil-lab
uv add --dev pytest
建立幾個測試用資料夾:
mkdir -p inbox reports archive
echo "user_id,total" > inbox/report-2026-06.csv
echo "1,120" >> inbox/report-2026-06.csv
echo "draft notes" > inbox/notes.txt
今天範例先放在 file_ops.py:
from pathlib import Path
import shutil
ROOT = Path(__file__).parent
INBOX = ROOT / "inbox"
REPORTS = ROOT / "reports"
ARCHIVE = ROOT / "archive"
拍拍君習慣先把根目錄定義清楚。 不要讓程式到處依賴「目前 shell 在哪個目錄」。 工作目錄一變,檔案就搬去奇怪地方,然後你就會開始懷疑人生。很合理,但沒必要。
三. copyfile、copy、copy2:複製單一檔案
#
shutil 最常見的第一組工具是檔案複製。
三個名字看起來很像:
shutil.copyfile(src, dst)shutil.copy(src, dst)shutil.copy2(src, dst)差別在於目標形式和保留多少 metadata。 先看最基本版本:
from pathlib import Path
import shutil
def copy_report(src: Path, dst: Path) -> Path:
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(src, dst)
return dst
copyfile() 的定位很單純:來源是檔案,目標也是檔案。
它不會幫你猜目錄,也不會保留權限。
如果目標父資料夾不存在,你要自己建立。
接著看 copy():
def copy_into_dir(src: Path, dst_dir: Path) -> Path:
dst_dir.mkdir(parents=True, exist_ok=True)
result = shutil.copy(src, dst_dir)
return Path(result)
copy() 的目標可以是目錄。
如果 dst_dir 是目錄,它會把檔案複製進去,檔名維持不變。
copy() 會保留檔案權限,但不會完整保留所有 metadata。
如果你想連修改時間也盡量保留,用 copy2():
def backup_file(src: Path, backup_dir: Path) -> Path:
backup_dir.mkdir(parents=True, exist_ok=True)
result = shutil.copy2(src, backup_dir)
return Path(result)
拍拍君的簡單判斷是:
- 只想精準複製內容到某個檔名:
copyfile() - 想複製進某個目錄:
copy() - 做備份、同步、保留修改時間:
copy2()真正重要的是:你要不要保留 metadata,以及目標是檔案還是目錄。
四. copytree:複製整個目錄
#
單一檔案簡單,整包資料夾就容易出事。
shutil.copytree() 可以複製整個目錄樹:
from pathlib import Path
import shutil
def copy_project_template(template_dir: Path, target_dir: Path) -> Path:
shutil.copytree(template_dir, target_dir)
return target_dir
預設情況下,如果 target_dir 已經存在,copytree() 會失敗。
這個預設其實很棒。
因為整包覆蓋資料夾太危險了。
如果你真的想允許目標資料夾存在,可以用 dirs_exist_ok=True:
def update_template_files(template_dir: Path, target_dir: Path) -> Path:
shutil.copytree(template_dir, target_dir, dirs_exist_ok=True)
return target_dir
但拍拍君建議你不要一開始就開這個選項。 先問自己:
- 目標資料夾裡如果有舊檔,要覆蓋嗎?
- 只要新增缺的檔案,還是要更新全部?
- 如果有使用者手動改過的檔案,該保留嗎?
- 失敗時能不能重跑?
copytree()也可以搭配 ignore function。 最常用的是shutil.ignore_patterns():
def copy_clean_source(src: Path, dst: Path) -> Path:
shutil.copytree(
src,
dst,
ignore=shutil.ignore_patterns(
".git",
"__pycache__",
".pytest_cache",
"*.pyc",
".venv",
),
)
return dst
這很適合做「打包乾淨範例專案」或「複製模板」。 如果規則更複雜,也可以自己寫 function:
def ignore_large_files(directory: str, names: list[str]) -> set[str]:
ignored: set[str] = set()
base = Path(directory)
for name in names:
path = base / name
if path.is_file() and path.stat().st_size > 10 * 1024 * 1024:
ignored.add(name)
return ignored
注意 ignore function 收到的是目前目錄和該層名稱,不是一次收到整棵樹。
五. move:搬移不是永遠等於 rename
#
shutil.move() 看起來像 mv:
def move_to_archive(src: Path, archive_dir: Path) -> Path:
archive_dir.mkdir(parents=True, exist_ok=True)
result = shutil.move(src, archive_dir)
return Path(result)
如果來源和目標在同一個檔案系統,底層可能只是 rename,很快。
如果跨檔案系統,例如從暫存磁碟搬到外接硬碟,它可能會變成「複製後刪除」。
這就是為什麼 move() 不是單純 rename。
如果你只是要在同一個目錄改名,Path.rename() 或 Path.replace() 會更明確。
重要檔案搬移前,拍拍君建議自己決定覆蓋策略:
def move_report_safely(src: Path, dst_dir: Path) -> Path:
dst_dir.mkdir(parents=True, exist_ok=True)
dst = dst_dir / src.name
if dst.exists():
raise FileExistsError(f"target already exists: {dst}")
result = shutil.move(src, dst)
return Path(result)
這比默默覆蓋好太多。 自動化工具的第一美德不是帥,是不要把資料弄丟。
六. make_archive 與 unpack_archive
#
shutil 也能處理常見壓縮檔。
先把 reports/ 打成 zip:
from pathlib import Path
import shutil
def archive_reports(report_dir: Path, output_dir: Path, name: str) -> Path:
output_dir.mkdir(parents=True, exist_ok=True)
base_name = output_dir / name
archive_path = shutil.make_archive(
base_name=str(base_name),
format="zip",
root_dir=report_dir,
)
return Path(archive_path)
base_name 不要加 .zip。
make_archive() 會依照 format 自己補副檔名。
常見格式可以這樣查:
import shutil
for name, extensions, description in shutil.get_archive_formats():
print(name, extensions, description)
解壓縮則用 unpack_archive():
def unpack_reports(archive_path: Path, extract_dir: Path) -> Path:
extract_dir.mkdir(parents=True, exist_ok=True)
shutil.unpack_archive(archive_path, extract_dir)
return extract_dir
不過解壓縮外部檔案時要小心。
惡意 archive 可能包含奇怪路徑,試圖把檔案解到目標資料夾之外。
如果來源不可信,請改用更細緻的 zipfile 或 tarfile,逐一檢查成員路徑後再解。
shutil.unpack_archive() 適合信任來源,例如自己剛剛用 make_archive() 產生的 artifacts。
七. 做一個批次整理工具 #
假設我們有一個 inbox/,裡面會出現報表、文字筆記和其他檔案。
規則是:
.csv複製到reports/- 原始檔搬到
archive/raw/ - 整理完把 reports 打成 zip
- 如果目標已存在就跳過,不覆蓋
from dataclasses import dataclass
from pathlib import Path
import shutil
@dataclass
class OrganizeResult:
copied: int = 0
moved: int = 0
skipped: int = 0
archive_path: Path | None = None
def organize_inbox(inbox: Path, reports: Path, archive: Path) -> OrganizeResult:
reports.mkdir(parents=True, exist_ok=True)
raw_archive = archive / "raw"
raw_archive.mkdir(parents=True, exist_ok=True)
result = OrganizeResult()
for src in sorted(inbox.iterdir()):
if not src.is_file():
result.skipped += 1
continue
if src.suffix == ".csv":
dst = reports / src.name
if dst.exists():
result.skipped += 1
else:
shutil.copy2(src, dst)
result.copied += 1
raw_dst = raw_archive / src.name
if raw_dst.exists():
result.skipped += 1
continue
shutil.move(src, raw_dst)
result.moved += 1
if any(reports.iterdir()):
archive_path = shutil.make_archive(
base_name=str(archive / "reports"),
format="zip",
root_dir=reports,
)
result.archive_path = Path(archive_path)
return result
這段程式很樸素,但已經比亂寫 shell 穩很多。 你可以清楚看到哪裡建立資料夾、哪裡決定覆蓋策略、哪裡複製、哪裡搬移、哪裡打包。 加一個簡單入口:
if __name__ == "__main__":
result = organize_inbox(
inbox=Path("inbox"),
reports=Path("reports"),
archive=Path("archive"),
)
print(f"copied: {result.copied}")
print(f"moved: {result.moved}")
print(f"skipped: {result.skipped}")
print(f"archive: {result.archive_path}")
如果要正式用,拍拍君會再加三件事:
- logging:記錄每個動作
- dry-run:先印出會做什麼,不真的執行
- tests:用
tmp_path建立測試資料夾
八. 加上 dry-run:先看再動手 #
檔案工具很適合加 dry_run。
尤其是第一次整理真實資料時,請不要直接衝。
from pathlib import Path
import shutil
def safe_copy(src: Path, dst: Path, *, dry_run: bool = False) -> None:
if dst.exists():
raise FileExistsError(dst)
print(f"copy {src} -> {dst}")
if dry_run:
return
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
搬移也一樣:
def safe_move(src: Path, dst: Path, *, dry_run: bool = False) -> None:
if dst.exists():
raise FileExistsError(dst)
print(f"move {src} -> {dst}")
if dry_run:
return
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.move(src, dst)
dry_run 不只是怕你手滑。
它也讓使用者可以先確認規則是否符合預期。
這種小細節會讓內部工具從「拍拍君自己敢用」變成「同事也敢用」。
九. 測試檔案操作:用 tmp_path
#
檔案操作一定要測。
但不要在 repo 裡真的建立一堆測試資料夾。
pytest 的 tmp_path 很適合:
from pathlib import Path
from file_ops import organize_inbox
def test_organize_inbox_copies_csv_and_archives_raw(tmp_path: Path) -> None:
inbox = tmp_path / "inbox"
reports = tmp_path / "reports"
archive = tmp_path / "archive"
inbox.mkdir()
(inbox / "report.csv").write_text("id,total\n1,120\n", encoding="utf-8")
(inbox / "note.txt").write_text("hello\n", encoding="utf-8")
result = organize_inbox(inbox, reports, archive)
assert result.copied == 1
assert result.moved == 2
assert (reports / "report.csv").exists()
assert (archive / "raw" / "report.csv").exists()
assert (archive / "raw" / "note.txt").exists()
assert result.archive_path is not None
assert result.archive_path.exists()
測試重點不是證明 shutil.copy2() 能運作。
標準庫自己已經測過了,謝謝。
你要測的是自己的規則:哪些檔案會被複製、哪些會被搬走、遇到已存在目標時怎麼辦、archive 是否建立、dry-run 是否真的不改資料。
十. 常見地雷 #
第一個地雷:把路徑字串手動相加。 不要這樣:
dst = "archive/" + filename
請用 Path:
dst = Path("archive") / filename
第二個地雷:沒有檢查目標是否存在。 重要資料請明確寫出策略:
if dst.exists():
raise FileExistsError(dst)
第三個地雷:對不信任 archive 直接解壓縮。
自己打包自己解,通常沒問題。
但如果壓縮檔是使用者上傳、網路下載、或來源不明,請不要無腦 unpack_archive()。
第四個地雷:用 rmtree 當普通清理工具。
shutil.rmtree() 可以刪整個目錄樹。
也就是說,它很強,也很危險。
拍拍君建議只在這幾種情況用:
- 目標是測試用
tmp_path - 目標是程式自己建立的暫存目錄
- 目標路徑有明確安全邊界檢查 例如:
def remove_generated_dir(path: Path, workspace: Path) -> None:
path = path.resolve()
workspace = workspace.resolve()
if workspace not in path.parents:
raise ValueError(f"refuse to remove path outside workspace: {path}")
shutil.rmtree(path)
檔案系統不會問你「你確定嗎?」。 它只會照做。很有效率,也很殘酷。
結語 #
shutil 不是華麗的模組。
它沒有酷炫語法,也不會讓你覺得自己在寫什麼高深架構。
但它很實用。
只要你的程式需要整理檔案、複製資料夾、打包 artifacts、搬移輸出、檢查磁碟空間,shutil 都會默默站在旁邊。
拍拍君建議你記住這幾個原則:
- 用
Path管路徑,用shutil做高階操作 - 預設不要覆蓋,覆蓋策略要明確
- 大量操作前加 dry-run
- 不信任的 archive 不要直接解
rmtree要加安全邊界- 檔案工具一定要用
tmp_path測試 檔案整理工具寫得好,平常沒人會稱讚你。 但它不會亂刪資料、不會亂覆蓋、不會把 archive 打包錯。 這已經是很高級的溫柔了。