要把 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/ 裡的資料檔 |
有幾類檔案會刻意略過:
__pycache__和.pyc:安裝後 Python 會自己重新產生。RECORD、INSTALLER、REQUESTED、direct_url.json:這些是 pip「安裝時」才寫的紀錄,不屬於 wheel。RECORD會在打包時依新的內容重新計算。- console_scripts 產生的指令:像
pip、httpx這類指令是 pip 依entry_points.txt產生的小程式,第一行寫死了這台機器的 python 路徑。不需要打包,到新機器安裝時 pip 會依entry_points.txt重新產生,路徑自然就對了。
一般 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. 哪些套件不會打包
pip、setuptools、wheel:這些是打包與安裝工具,新建的 venv 已經有 pip,其他兩個通常也用不到,預設略過;加上--include-pip就會一起打包。- editable 安裝(
pip install -e):site-packages裡只有一個指回原始碼資料夾的連結,打包了也沒用。這類套件請到原始碼資料夾另外用pip wheel --no-deps -w wheelhouse .打包,再手動加進requirements.txt。
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。
要注意的地方
- 作業系統、CPU 架構、Python 版本都要一樣:重新打包只是把現有檔案換個格式,不會重新編譯。含
.so/.pyd的套件,在 Linux x86_64 + Python 3.14 打包的 wheel,就只能裝在同樣的環境。純 Python 套件(py3-none-any)則沒有這個限制。 - 不是在 venv 裡執行時:腳本會顯示
⚠ 目前不是在 venv 裡執行的警告,但仍會繼續打包。在系統 Python 執行會把一大堆不相關的套件打包進來;在 conda 環境執行則只會打包用 pip 安裝的套件,用conda install裝的套件沒有RECORD,會在注意事項裡列為略過。 - 版本固定:
requirements.txt用==鎖定每個套件的版本,裝出來的就是原本那一套,不會多也不會少。