快轉到主要內容
  1. 教學文章/

Python shutil 實戰:檔案複製、搬移、壓縮與安全清理

·7 分鐘· loading · loading · ·
Python Shutil Filesystem Automation Standard-Library Developer-Tools
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 76: 本文

featured

一. 前言:檔案整理不是 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 在哪個目錄」。 工作目錄一變,檔案就搬去奇怪地方,然後你就會開始懷疑人生。很合理,但沒必要。

三. copyfilecopycopy2:複製單一檔案
#

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_archiveunpack_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 不要加 .zipmake_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 可能包含奇怪路徑,試圖把檔案解到目標資料夾之外。 如果來源不可信,請改用更細緻的 zipfiletarfile,逐一檢查成員路徑後再解。 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 裡真的建立一堆測試資料夾。 pytesttmp_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 打包錯。 這已經是很高級的溫柔了。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 76: 本文

相關文章

Python tempfile 實戰:安全建立暫存檔案、目錄與測試資料
·9 分鐘· loading · loading
Python Tempfile Filesystem Testing Standard-Library Developer-Tools
Python watchdog 實戰:檔案變更監控與自動化完全攻略
·8 分鐘· loading · loading
Python Watchdog Automation Filesystem Developer-Tools
Python inspect 實戰:看懂函式簽名、物件結構與開發工具自動化
·6 分鐘· loading · loading
Python Inspect Introspection Standard-Library Developer-Tools
Python uv scripts 實戰:PEP 723、inline dependencies 與單檔工具
·6 分鐘· loading · loading
Python Uv PEP 723 Script Developer-Tools Automation
Python AnyIO 實戰:TaskGroup、取消管理與跨框架非同步工具
·6 分鐘· loading · loading
Python AnyIO Async Asyncio Trio Developer-Tools
Rich Logging Dashboard 實戰:進度、表格與 Log Console 整合
·7 分鐘· loading · loading
Python Rich Logging Cli Dashboard Developer-Tools