一. 前言:套件裝好了,然後呢? #
寫 Python 工具時,你遲早會想知道:目前環境裝的是哪個版本?某個套件宣告支援哪些 Python 版本?它安裝了哪些檔案?import 名稱和 PyPI 名稱為什麼不同?能不能讓第三方套件自動註冊外掛?
很多人第一反應是呼叫 pip show,再用 subprocess 解析終端機輸出。先不要。
Python 3.8 起,標準庫提供 importlib.metadata,可以直接讀取目前環境裡已安裝 distribution 的 metadata,不需要啟動另一個 process,也不需要猜 pip 的文字格式。
這篇會從版本查詢開始,一路做到依賴診斷、檔案盤點與 entry-point 外掛探索。
如果你要學「如何把自己的專案打包」,先看 Python Packaging 教學;要用 uv 建 wheel、發 TestPyPI 或 private index,則看 uv build/publish 實戰。今天的方向相反:讀取已經安裝完成的套件資訊。
二. 安裝與基本觀念 #
2.1 Python 3.8 以上不用安裝 #
importlib.metadata 是標準庫:
from importlib import metadata
print(metadata.version("pip"))
若必須支援舊版 Python,可以安裝 backport:
python -m pip install importlib-metadata
舊版環境改成 from importlib_metadata import version;本文以下都使用標準庫版本。
2.2 Distribution 名稱不等於 import 名稱 #
先記住最容易踩到的差異:
安裝名稱 / distribution name:PyYAML
匯入名稱 / import package:yaml
安裝名稱:beautifulsoup4
匯入名稱:bs4
importlib.metadata 多數 API 接受的是 distribution name,也就是安裝工具和 package metadata 認得的名稱。它不是拿來檢查任意 module 是否能 import 的。
三. 查詢版本:別再解析 pip show #
最常用的函式是 version():
from importlib.metadata import version
httpx_version = version("httpx")
print(f"目前的 httpx 版本:{httpx_version}")
它回傳字串,而不是可以直接比較大小的版本物件。若需要判斷版本範圍,搭配 packaging.version.Version:
python -m pip install packaging
from importlib.metadata import version
from packaging.version import Version
current = Version(version("httpx"))
if current < Version("0.27"):
raise RuntimeError("請升級 httpx 至 0.27 以上")
能交給 dependency resolver 處理的限制,仍應優先寫進 pyproject.toml。Runtime 檢查適合顯示診斷資訊、產生 bug report,或檢查外部載入的 plugin;不要在應用程式裡重做整套 dependency resolution。
四. 正確處理「套件不存在」 #
查詢未安裝的 distribution 時,API 會丟出 PackageNotFoundError:
from importlib.metadata import PackageNotFoundError, version
def installed_version(name: str) -> str | None:
try:
return version(name)
except PackageNotFoundError:
return None
print(installed_version("httpx"))
print(installed_version("pypy-definitely-not-installed"))
不要用裸的 except Exception,否則連權限、檔案損壞或程式錯誤都會被吞掉。如果是必要依賴,可以轉成更清楚的訊息:
def require_distribution(name: str) -> str:
try:
return version(name)
except PackageNotFoundError as exc:
raise RuntimeError(
f"缺少必要套件 {name!r},請先同步專案依賴"
) from exc
五. 讀取完整 metadata #
metadata() 會回傳類似 email message 的 mapping:
from importlib.metadata import metadata
info = metadata("httpx")
print(info["Name"])
print(info["Version"])
print(info.get("Summary"))
print(info.get("Requires-Python"))
常見欄位包括 Name、Version、Summary、Requires-Python、License、Classifier、Requires-Dist 與 Project-URL。
有些欄位可以出現很多次,這時用 get_all():
for url in info.get_all("Project-URL") or []:
print(url)
for classifier in info.get_all("Classifier") or []:
if classifier.startswith("Programming Language :: Python"):
print(classifier)
若要交給 JSON API,metadata 物件也提供 JSON-compatible 表示:
import json
print(json.dumps(info.json, ensure_ascii=False, indent=2))
Metadata 是套件作者宣告的資料,不代表每個欄位都一定存在。對 optional 欄位使用 .get(),比直接假設它一定有值可靠。
六. 用 Distribution 一次取得多種資訊 #
如果同一個套件要查很多項目,可以先取得 Distribution 物件:
from importlib.metadata import distribution
dist = distribution("httpx")
print(dist.name)
print(dist.version)
print(dist.metadata.get("Summary"))
print(dist.metadata.get("Requires-Python"))
它也能提供 files、requires、entry_points,新版本 Python 還可透過 origin 查看安裝來源資訊。
from importlib.metadata import PackageNotFoundError, distribution
def inspect_distribution(name: str) -> dict[str, object]:
try:
dist = distribution(name)
except PackageNotFoundError:
return {"name": name, "installed": False}
return {
"name": dist.name,
"installed": True,
"version": dist.version,
"summary": dist.metadata.get("Summary"),
"dependency_count": len(dist.requires or []),
"file_count": len(dist.files or []),
}
七. 盤點套件安裝了哪些檔案 #
files() 會列出 distribution 記錄中的檔案:
from importlib.metadata import files
paths = files("httpx") or []
for path in paths[:10]:
print(path)
回傳的 PackagePath 類似 pathlib.PurePath,還可能帶有 size、hash 和所屬 distribution 資訊。若要找到實際位置,可以呼叫 locate():
for path in paths:
if path.name == "__init__.py":
print(path.locate())
break
這適合用於 bug report、確認資料檔是否被打進 wheel,或稽核某個 distribution 的安裝內容。
如果安裝資料庫沒有保存檔案清單,files() 可能回傳 None,所以要寫成 files("httpx") or []。另外,locate() 是診斷工具,不是修改邀請函;不要直接改 site-packages 裡的檔案。
八. 查看套件宣告的依賴 #
requires() 會回傳套件宣告的 requirement 字串:
from importlib.metadata import requires
dependencies = requires("httpx") or []
for requirement in dependencies:
print(requirement)
內容會保留版本條件、environment marker 和 extras。若要可靠解析,不要自己 split():
from importlib.metadata import requires
from packaging.requirements import Requirement
def normalized_requirements(name: str) -> list[Requirement]:
return [Requirement(item) for item in requires(name) or []]
for requirement in normalized_requirements("httpx"):
print("名稱:", requirement.name)
print("版本:", requirement.specifier)
print("marker:", requirement.marker)
print("extras:", sorted(requirement.extras))
requires() 告訴你的是 宣告內容,不是 resolver 已驗證過的完整 dependency graph。檢查目前環境一致性仍可使用:
python -m pip check
九. import 名稱來自哪個 distribution? #
packages_distributions() 可以建立 import name 到 distribution 的對照:
from importlib.metadata import packages_distributions
mapping = packages_distributions()
print(mapping.get("yaml"))
print(mapping.get("bs4"))
結果是 list,因為 namespace package 可能由多個 distribution 共同提供:
def providers(import_name: str) -> list[str]:
return packages_distributions().get(import_name, [])
for dist_name in providers("yaml"):
print(dist_name)
Editable install 有時不會提供完整 top-level name 記錄,所以把這個對照當成診斷線索,不要當成安全邊界。
十. 用 entry points 發現外掛 #
Entry point 是 distribution 公開宣告的擴充介面。假設主程式定義 plugin group pypy_report.formatters,第三方外掛可在 pyproject.toml 宣告:
[project.entry-points."pypy_report.formatters"]
markdown = "pypy_report_markdown:render"
主程式不用知道外掛的實際套件名稱,只要掃描這個 group:
from importlib.metadata import entry_points
formatters = entry_points(group="pypy_report.formatters")
for plugin in formatters:
print(plugin.name)
print(plugin.value)
print(plugin.group)
要載入外掛指向的物件,呼叫 load():
from collections.abc import Callable
def load_formatters() -> dict[str, Callable[..., object]]:
loaded: dict[str, Callable[..., object]] = {}
for plugin in entry_points(group="pypy_report.formatters"):
target = plugin.load()
if not callable(target):
raise TypeError(f"plugin {plugin.name!r} 不是 callable")
loaded[plugin.name] = target
return loaded
這個模式讓主程式和外掛分開發布,新外掛安裝後即可被探索,也不必維護手寫的 import 清單。
十一. 外掛載入要做防護 #
EntryPoint.load() 會 import 並回傳目標物件。也就是說,載入不受信任的外掛可能執行任意 Python 程式碼。Entry point 是 discovery 機制,不是 sandbox。
實務上至少要處理外掛 import 失敗、依賴缺失、名稱衝突、API 版本不相容,以及啟動時間被外掛拖慢:
import logging
from collections.abc import Callable
from importlib.metadata import EntryPoint
logger = logging.getLogger(__name__)
def safe_load(plugin: EntryPoint) -> Callable[..., object] | None:
try:
loaded = plugin.load()
except (ImportError, AttributeError) as exc:
logger.warning("無法載入 %s: %s", plugin.name, exc)
return None
if not callable(loaded):
logger.warning("plugin %s 不是 callable", plugin.name)
return None
return loaded
如果外掛來自使用者可自由安裝的環境,最好再提供 allowlist、disable list,必要時放進隔離 process。
十二. 常見陷阱 #
- 把 import 名稱直接丟給
version():yaml的 distribution name 是PyYAML;不確定時用packages_distributions()找 provider。 - 用字串比較版本:
"2.10"和"2.9"不能代表版本先後,需要比較時用packaging.version.Version。 - 假設欄位與清單一定存在: optional metadata 用
.get(),檔案和依賴用files("demo") or []、requires("demo") or []。 - 把 plugin discovery 當安全隔離:
load()會執行 import,只載入信任或允許的 distribution,必要時放到獨立 process。 - 在應用程式裡重做 resolver: Metadata API 能讀宣告,但不該取代 pip、uv 或其他 installer/resolver。
結語 #
importlib.metadata 不花俏,卻把原本需要呼叫 pip、掃 site-packages 或依賴舊式 metadata 工具的工作,整理成標準庫 API。
今天最值得帶走的幾件事:
version()查的是 distribution name;metadata()與distribution()適合做診斷資訊;files()、requires()可能回傳None;packages_distributions()能處理 import 名稱差異;entry_points()能做 plugin discovery,但不是 sandbox。
下次想寫 subprocess.run(["pip", "show", ...]) 時,先停一下,看看標準庫是不是已經把資料遞到你手上了。
拍拍君先把那段脆弱的文字解析器收進抽屜。不是不能用,只是今天真的用不到。📦