Skip to content
Greg's Space
Go back

沒有網路也能搬 Python 環境:把 venv 裡已安裝的套件重新打包成 .whl

要把 Python 環境搬到一台不能上網的機器,最常見的做法是 pip download 先把 wheel 抓下來。但如果現在這台機器也連不上 PyPI,或者只是想「現在能跑的這一套原封不動搬過去」,pip download 就派不上用場了。

其實已安裝的套件在 site-packages 裡都還留著完整的檔案和一份檔案清單(RECORD),只要照 wheel 的格式重新裝進 zip,就能還原出可以安裝的 .whl。這篇的 repack_wheels.py 就是做這件事,而且只用 Python 標準函式庫。

使用方式

一定要用目標 venv 裡的 python 執行,腳本才會打包那個 venv 的套件:

# Linux / macOS
.venv/bin/python repack_wheels.py -o wheelhouse

# Windows
.venv\Scripts\python repack_wheels.py -o wheelhouse

會產生:

wheelhouse/
├── *.whl
└── requirements.txt

把整個 wheelhouse/ 複製到另一台機器,同樣不需要網路就能安裝:

python -m venv .venv
.venv/bin/pip install --no-index --find-links wheelhouse -r wheelhouse/requirements.txt

--no-index 表示不連 PyPI,--find-links wheelhouse 表示只從這個資料夾找套件。

完整程式碼

檔案位置:repack_wheels.py

"""
把目前 venv 裡「已安裝」的套件重新打包成 .whl,完全不需要網路。

用法(務必用目標 venv 的 python 執行):
    Linux/macOS: .venv/bin/python repack_wheels.py -o wheelhouse
    Windows:     .venv\\Scripts\\python repack_wheels.py -o wheelhouse

產出:
    wheelhouse/*.whl
    wheelhouse/requirements.txt

在另一台機器安裝(同樣不需網路):
    python -m venv .venv
    .venv/bin/pip install --no-index --find-links wheelhouse -r wheelhouse/requirements.txt
"""
import argparse
import base64
import hashlib
import json
import re
import sys
import sysconfig
import zipfile
from importlib import metadata
from pathlib import Path

# 這些檔案是「安裝時」才產生的,不應放進 wheel
INSTALLER_FILES = {"RECORD", "INSTALLER", "REQUESTED", "direct_url.json"}

def record_hash(data: bytes) -> str:
    digest = hashlib.sha256(data).digest()
    return "sha256=" + base64.urlsafe_b64encode(digest).rstrip(b"=").decode()


def escape(text: str) -> str:
    """wheel 檔名與 dist-info 資料夾名稱用的跳脫規則"""

    return re.sub(r"[-_.]+", "_", text)


def is_editable(dist: metadata.Distribution) -> bool:
    """用 pip install -e 裝的套件,site-packages 裡只有一個連結,打包了也沒用"""

    text = dist.read_text("direct_url.json")
    if not text:
        return False
    return bool(json.loads(text).get("dir_info", {}).get("editable"))


def read_tags(dist: metadata.Distribution, has_binary: bool) -> tuple[str, list[str]]:
    """從 dist-info/WHEEL 讀出 Root-Is-Purelib 與 Tag;沒有的話自己推算"""

    wheel_text = dist.read_text("WHEEL")
    if wheel_text:
        tags = re.findall(r"^Tag:\s*(\S+)", wheel_text, re.M)
        purelib = re.search(r"^Root-Is-Purelib:\s*(\S+)", wheel_text, re.M)
        if tags:
            return (purelib.group(1) if purelib else "true"), tags

    if not has_binary:
        return "true", ["py3-none-any"]

    py = f"cp{sys.version_info.major}{sys.version_info.minor}"
    plat = re.sub(r"[-.]", "_", sysconfig.get_platform())
    return "false", [f"{py}-{py}-{plat}"]


def compress_tags(tags: list[str]) -> str:
    """多個 tag 合併成檔名格式,例如 py2.py3-none-any"""

    parts = [tag.split("-") for tag in tags]
    pythons, abis, plats = (sorted({p[i] for p in parts}) for i in range(3))
    return "-".join(".".join(group) for group in (pythons, abis, plats))


def fix_shebang(data: bytes) -> bytes:
    """一般 script 的 shebang 寫死了這台機器的 python 路徑,改回 #!python 讓 pip 重新填"""

    if data.startswith(b"#!") and b"python" in data.split(b"\n", 1)[0]:
        return b"#!python" + data[data.index(b"\n"):] if b"\n" in data else b"#!python\n"
    return data


def repack(
    dist: metadata.Distribution, out_dir: Path
) -> tuple[tuple[str, str, str] | None, str | None]:
    """
    把一個已安裝的套件打包成 wheel。
    回傳 ((名稱, 版本, wheel 檔名), 注意事項);無法打包時名稱那一格是 None。
    """

    name = dist.metadata["Name"]
    version = dist.version

    if is_editable(dist):
        return None, f"略過 {name}:editable 安裝,請到原始碼資料夾用 pip wheel 另外打包"
    if dist.files is None:
        return None, f"略過 {name}:沒有 RECORD,無法得知檔案清單"

    base = Path(dist.locate_file("")).resolve()
    prefix = Path(sys.prefix).resolve()
    scripts_dir = Path(sysconfig.get_paths()["scripts"]).resolve()

    # console_scripts / gui_scripts 由 pip 依 entry_points.txt 重新產生,不必打包
    entry_scripts = {
        ep.name.lower() for ep in dist.entry_points
        if ep.group in ("console_scripts", "gui_scripts")
    }

    # 名稱把 - _ . 都換成 _;版本號只換 -,. 要保留
    stem = f"{escape(name)}-{version.replace('-', '_')}"
    dist_info = f"{stem}.dist-info"
    data_dir = f"{stem}.data"
    entries: list[tuple[str, bytes]] = []
    outside: list[str] = []
    has_binary = False

    for package_path in dist.files:
        source = Path(dist.locate_file(package_path)).resolve()
        if not source.is_file():
            continue
        if source.suffix == ".pyc" or "__pycache__" in source.parts:
            continue

        data = source.read_bytes()

        # site-packages 裡的檔案(含 dist-info)
        if source.is_relative_to(base):
            parts = source.relative_to(base).parts
            if parts[0].endswith(".dist-info"):
                if len(parts) == 2 and parts[1] in INSTALLER_FILES | {"WHEEL"}:
                    continue
                arcname = "/".join((dist_info, *parts[1:]))
            else:
                arcname = "/".join(parts)
            if source.suffix in (".so", ".pyd", ".dylib", ".dll"):
                has_binary = True

        # bin/ 或 Scripts/ 裡的檔案
        elif source.is_relative_to(scripts_dir):
            script_name = source.name.lower().removesuffix(".exe").removesuffix("-script.py")
            if script_name in entry_scripts:
                continue
            arcname = f"{data_dir}/scripts/{source.name}"
            data = fix_shebang(data)

        # venv 底下的其他資料檔,例如 share/、include/
        elif source.is_relative_to(prefix):
            relative = source.relative_to(prefix).as_posix()
            arcname = f"{data_dir}/data/{relative}"

        else:
            outside.append(str(source))
            continue

        entries.append((arcname, data))

    root_is_purelib, tags = read_tags(dist, has_binary)
    wheel_meta = (
        "Wheel-Version: 1.0\n"
        "Generator: repack_wheels\n"
        f"Root-Is-Purelib: {root_is_purelib}\n"
        + "".join(f"Tag: {tag}\n" for tag in tags)
    )
    entries.append((f"{dist_info}/WHEEL", wheel_meta.encode()))

    filename = f"{stem}-{compress_tags(tags)}.whl"
    wheel_path = out_dir / filename

    record_lines = []
    with zipfile.ZipFile(wheel_path, "w", zipfile.ZIP_DEFLATED) as wheel:
        for arcname, data in entries:
            wheel.writestr(arcname, data)
            record_lines.append(f"{arcname},{record_hash(data)},{len(data)}")
        record_lines.append(f"{dist_info}/RECORD,,")
        wheel.writestr(f"{dist_info}/RECORD", "\n".join(record_lines) + "\n")

    note = None
    if outside:
        note = f"{name}:略過 {len(outside)} 個 venv 以外的檔案,例如 {outside[0]}"
    return (name, version, filename), note


def main() -> None:
    parser = argparse.ArgumentParser(description="把目前 venv 已安裝的套件重新打包成 .whl")
    parser.add_argument("-o", "--out", default="wheelhouse", help="輸出資料夾")
    parser.add_argument("--include-pip", action="store_true",
                        help="連 pip、setuptools、wheel 也打包")
    args = parser.parse_args()

    if sys.prefix == sys.base_prefix:
        print("⚠ 目前不是在 venv 裡執行,會打包系統 Python 的套件。請改用 .venv 的 python。")

    out_dir = Path(args.out)
    out_dir.mkdir(parents=True, exist_ok=True)

    paths = list(dict.fromkeys([sysconfig.get_paths()["purelib"], sysconfig.get_paths()["platlib"]]))
    seen, done, notes = set(), [], []
    for dist in metadata.distributions(path=paths):
        key = re.sub(r"[-_.]+", "-", dist.metadata["Name"]).lower()
        if key in seen:
            continue
        seen.add(key)
        if not args.include_pip and key in {"pip", "setuptools", "wheel"}:
            continue
        result, note = repack(dist, out_dir)
        if result:
            done.append(result)
            print(f"✔ {result[2]}")
        if note:
            notes.append(note)

    req = out_dir / "requirements.txt"
    req.write_text("".join(f"{n}=={v}\n" for n, v, _ in sorted(done, key=lambda x: x[0].lower())),
                   encoding="utf-8")

    print(f"\n完成:{len(done)} 個 wheel → {out_dir.resolve()}")
    if notes:
        print("\n注意事項:")
        for n in notes:
            print("  -", n)


if __name__ == "__main__":
    main()

運作原理

1. 找出 venv 裡有哪些套件

paths = list(dict.fromkeys([sysconfig.get_paths()["purelib"], sysconfig.get_paths()["platlib"]]))
for dist in metadata.distributions(path=paths):

sysconfig.get_paths() 會給出目前這個 python 的 site-packages 位置,importlib.metadata.distributions() 再把裡面每個 *.dist-info 讀成一個套件物件。這也是為什麼一定要用目標 venv 的 python 執行:換一個 python,找到的就是另一個 site-packages。

有些系統的 purelib 和 platlib 是兩個不同的資料夾(例如 lib 和 lib64),所以兩個都要掃。dict.fromkeys() 用來去掉重複的路徑並保留原本的順序;套件本身則用正規化後的名稱(-、_、. 統一成 - 並轉小寫)去重。

2. 用 RECORD 拿到每個套件的檔案清單

pip 安裝一個 wheel 時,會把裝了哪些檔案寫進 dist-info/RECORD,dist.files 就是讀這份清單。有了它,就能精確知道某個套件擁有哪些檔案,不會誤把別的套件的檔案打包進來。

沒有 RECORD 的套件(例如 conda 裝的、或很舊的 setup.py install)無法得知檔案清單,會略過,並在最後的「注意事項」列出來。

3. 把檔案分成三類放進 wheel

原本的位置放進 wheel 的位置說明
site-packages/ 底下wheel 根目錄套件本體和 dist-info
bin/ 或 Scripts/<名稱>-<版本>.data/scripts/一般的 script,shebang 改回 #!python
venv 底下其他地方<名稱>-<版本>.data/data/例如 share/ 裡的資料檔

有幾類檔案會刻意略過:

一般 script 沒有 entry point 可以重新產生,所以把第一行的 shebang 改成 #!python。這是 wheel 規格裡的約定,pip 安裝時會把它換成新 venv 的 python 路徑。

4. 寫回 WHEEL 與 RECORD

wheel 的檔名格式是 名稱-版本-python標籤-abi標籤-平台標籤.whl,例如:

pydantic_core-2.46.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
personal_assistant-0.1.0-py3-none-any.whl

這些標籤原本就記在已安裝套件的 dist-info/WHEEL 裡,read_tags() 會直接沿用;只有找不到時才自己推算。compress_tags() 會把多個標籤合併成檔名的格式,例如 py2-none-any 和 py3-none-any 合併成 py2.py3-none-any。

名稱和版本號的跳脫規則不同:名稱裡的 -、_、. 都換成 _,版本號只換 -。如果把 0.1.0 也換成 0_1_0,pip 會認不出版本,安裝時出現 No matching distribution found。

最後把每個檔案的 SHA-256 與大小寫進新的 RECORD,pip 安裝時會用它來檢查檔案是否完整。

5. 哪些套件不會打包

repack() 會回傳 (result, note) 兩個值:result 是 (名稱, 版本, wheel 檔名),無法打包時是 None;note 是需要提醒的事情,例如為什麼略過、有沒有 venv 以外的檔案沒打包到。main() 把成功的結果寫進 requirements.txt,再把所有 note 集中在最後的「注意事項」一次列出,不會和打包進度混在一起。

實測結果

我用一個裝好終端機個人助理所有依賴的環境(Python 3.14,Linux)測試:

$ python repack_wheels.py -o wheelhouse
✔ google_genai-2.25.0-py3-none-any.whl
✔ markdown_it_py-4.2.0-py3-none-any.whl
✔ pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
...

完成:77 個 wheel → /path/to/wheelhouse

$ python -m venv .venv
$ .venv/bin/pip install --no-index --find-links wheelhouse -r wheelhouse/requirements.txt
$ .venv/bin/pip check
No broken requirements found.

77 個套件全部離線安裝成功,numpy、pydantic_core 這類含編譯檔的套件可以正常 import,lpa 這類指令也在新的 venv 裡重新產生,shebang 指向新的 python。

要注意的地方



Previous Post
終端機個人助理(一):用 cfg.py 管理工作資料夾與模型設定
Next Post
內網沒有 ffmpeg 怎麼辦?用 PyAV 把影片轉成聲音檔,再擷取想要的時間區間