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

Python importlib.metadata 實戰:套件版本、依賴資訊與外掛探索

·7 分鐘· loading · loading · ·
Python Importlib.metadata Packaging Metadata Entry-Points Plugins
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 98: 本文

featured

一. 前言:套件裝好了,然後呢?
#

寫 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"))

常見欄位包括 NameVersionSummaryRequires-PythonLicenseClassifierRequires-DistProject-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"))

它也能提供 filesrequiresentry_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。

今天最值得帶走的幾件事:

  1. version() 查的是 distribution name;
  2. metadata()distribution() 適合做診斷資訊;
  3. files()requires() 可能回傳 None
  4. packages_distributions() 能處理 import 名稱差異;
  5. entry_points() 能做 plugin discovery,但不是 sandbox。

下次想寫 subprocess.run(["pip", "show", ...]) 時,先停一下,看看標準庫是不是已經把資料遞到你手上了。

拍拍君先把那段脆弱的文字解析器收進抽屜。不是不能用,只是今天真的用不到。📦

延伸閱讀
#

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

相關文章

Python uv build/publish 實戰:從 wheel 到 private package workflow
·11 分鐘· loading · loading
Python Uv Packaging Wheel PyPI Private-Package Developer-Tools
Python graphlib 實戰:拓撲排序、依賴圖與工作流程排程
·5 分鐘· loading · loading
Python Graphlib TopologicalSorter DAG Topological-Sort Workflow
Python fsspec 實戰:統一讀寫本機、S3、HTTP 與資料管線路徑
·7 分鐘· loading · loading
Python Fsspec Filesystem S3 Data-Engineering ETL
Python OpenTelemetry 實戰:Trace、Span 與 FastAPI 觀測流程
·6 分鐘· loading · loading
Python OpenTelemetry Observability FastAPI Tracing Developer-Tools
Streamlit + SQLModel 實戰:做一個本機 CRUD 小後台
·9 分鐘· loading · loading
Python Streamlit SQLModel SQLite CRUD Developer-Tools
Python socket 實戰:TCP client/server、timeout 與簡易通訊協定
·8 分鐘· loading · loading
Python Socket TCP Networking Standard-Library Developer-Tools