同梱するPythonスクリプトの依存ライセンスを調べた。License欄だけ見ると4件が空だった

同梱するPythonスクリプトの依存ライセンスを調べた。License欄だけ見ると4件が空だった

12個の依存パッケージを調べて、4個が「ライセンス不明」と出ました。どれも誰もが使う定番です。不明のはずがありません。直すべきは依存ではなく、私の検査のほうでした。

デジタル商品にPythonスクリプトを付けるとき、importが1行増えるたびに、他人のライセンス条件も商品に付いてきます。AIに下書きさせたコードでも、自分で書いたコードでも同じです。請求書のExcelやWordを整えるスクリプトを想定した見本の環境で、同梱前に何を確かめられるかを試しました。実在の商品や販売実績の話ではありません。

見本の環境を作ります

依存は、requests、chardet、pyyaml、python-docx、openpyxlの5つです。空のディレクトリに仮想環境を作り、入れました。Pythonは3.13.5です。

python3 -m venv .venv
. .venv/bin/activate
pip install -q requests chardet pyyaml python-docx openpyxl

-qを付けたので、出力はありません。ただ、推移的依存(入れたパッケージが内部で必要とするパッケージ)も一緒に入ります。結果として、pipを除いて12個がそろいました。

最初の版はLicense欄だけを見ました

メタデータにはLicenseという欄があるので、そこが空かどうかを数える短いスクリプトを書きました。

check_v1.py の全文(13行)
from importlib.metadata import distributions

blank = []
for d in distributions():
    name = d.metadata["Name"]
    if name == "pip":
        continue
    if not d.metadata.get("License"):
        blank.append(name)

print("License欄が空:", len(blank), "件")
for n in sorted(blank, key=str.lower):
    print(" -", n)

保存名はcheck_v1.pyです。実行すると、こうなりました。

python check_v1.py
License欄が空: 4 件
 - chardet
 - idna
 - typing_extensions
 - urllib3

4件。urllib3が空なのは変です。requestsが必ず使うパッケージだからです。そこで、4件それぞれのメタデータ全体を見ました。chardetにはLicense-Expression: 0BSD、idnaにはBSD-3-Clause、typing_extensionsにはPSF-2.0、urllib3にはMITが入っていました。

欄が空なのではありません。書く場所が違っただけでした。

3つの欄を順に読む版に直しました

PEP 639は、ライセンスをSPDX形式の式(MITやBSD-3-Clauseのような短い識別子)で書くLicense-Expression欄を追加しました。同時に、従来のLicense欄とライセンス用のclassifierは非推奨にしています。今回の12個には、新しい欄に書くパッケージと従来の欄に書くパッケージが混ざっていました。1つの欄だけ読むと、どちらかを取りこぼします。

読む順番は、License-Expression、License、classifierです。見つけた文字列を3つに分けます。

  • STOP: LGPL、GPL、AGPLを含むもの。同梱の前に人が止まります
  • REVIEW: MPL、EPLなど。同梱できるかもしれませんが、人が条件を読みます
  • OK: MIT、BSD、Apache、PSFなど

どれにも当たらない文字列と、どの欄にも値がないものはUNKNOWNです。STOPかUNKNOWNが1件でもあれば、終了コード2で終わります。

check_licenses.py の全文(45行)
import re
import sys
from importlib.metadata import distributions

# 左から順に最初に当たった分類を使う(LGPL と AGPL を GPL より先に見る)
RULES = [
    ("STOP", r"AGPL|AFFERO|LGPL|LESSER|\bGPL|GENERAL PUBLIC"),
    ("REVIEW", r"MPL|MOZILLA|EPL|ECLIPSE|CDDL"),
    ("OK", r"\bMIT\b|0BSD|BSD|APACHE|PSF|PYTHON SOFTWARE|\bISC\b"),
]


def declared(meta):
    """License-Expression -> License -> Classifier の順で最初に見つかった値を返す"""
    if meta.get("License-Expression"):
        return "License-Expression", meta["License-Expression"]
    if meta.get("License"):
        return "License", meta["License"].splitlines()[0]
    classes = [c for c in (meta.get_all("Classifier") or []) if c.startswith("License ::")]
    if classes:
        return "Classifier", classes[0].split(" :: ")[-1]
    return "-", ""


def classify(text):
    t = text.upper()
    for label, pattern in RULES:
        if re.search(pattern, t):
            return label
    return "UNKNOWN"


stop = False
print(f"{'name':20} {'ver':9} {'source':19} {'declared':32} verdict")
for d in sorted(distributions(), key=lambda d: d.metadata["Name"].lower()):
    name = d.metadata["Name"]
    if name == "pip":
        continue
    src, text = declared(d.metadata)
    verdict = classify(text)
    if verdict in ("STOP", "UNKNOWN"):
        stop = True
    print(f"{name:20} {d.version:9} {src:19} {text[:30]:32} {verdict}")

sys.exit(2 if stop else 0)

同じ環境で動かします。

python check_licenses.py
echo "exit=$?"
name                 ver       source              declared                         verdict
certifi              2026.10.23 License             MPL-2.0                          REVIEW
chardet              7.7.0     License-Expression  0BSD                             OK
charset-normalizer   3.5.2     License             MIT                              OK
et_xmlfile           2.0.0     License             MIT                              OK
idna                 3.20      License-Expression  BSD-3-Clause                     OK
lxml                 6.1.3     License             BSD-3-Clause                     OK
openpyxl             3.1.5     License             MIT                              OK
python-docx          1.2.0     License             MIT                              OK
PyYAML               6.0.3     License             MIT                              OK
requests             2.34.2    License             Apache-2.0                       OK
typing_extensions    4.16.0    License-Expression  PSF-2.0                          OK
urllib3              2.8.0     License-Expression  MIT                              OK
exit=0

空に見えた4件は、すべてLicense-Expressionから読めました。止まるものはありません。ただしcertifiはMPL-2.0で、REVIEWのまま残っています。人が見る対象を1件に減らしただけ。それがこの検査の成果です。

古いchardetの環境なら止まるか

検査が通っただけでは、検査が働いている証拠になりません。止まる入力でも試します。chardetを5.2.0に固定した別の環境です。

python3 -m venv .venv-old
. .venv-old/bin/activate
pip install -q requests "chardet==5.2.0"
python check_licenses.py
echo "exit=$?"
name                 ver       source              declared                         verdict
certifi              2026.10.23 License             MPL-2.0                          REVIEW
chardet              5.2.0     License             LGPL                             STOP
charset-normalizer   3.5.2     License             MIT                              OK
idna                 3.20      License-Expression  BSD-3-Clause                     OK
requests             2.34.2    License             Apache-2.0                       OK
urllib3              2.8.0     License-Expression  MIT                              OK
exit=2

chardetは、5.2.0がLGPL、7.7.0が0BSDと宣言していました。パッケージ名が同じでも、版でライセンスが変わっています。商品に同梱するなら、requirements.txtで版を固定し、その版で検査するのが筋だと考えています。購入者の環境で後から更新されても、検査したときの条件は変わらないからです。

まだ納得していないこと

この検査には、わかっている穴が3つあります。

1つ目は、メタデータは作者の自己申告だという点です。宣言と実際のライセンス本文が食い違っていても、この検査は気づきません。2つ目は、分類が正規表現の文字列一致で、粗いことです。「GPL互換のMIT」のような文があれば、誤ってSTOPにするでしょう。3つ目は、REVIEWの判断を機械に任せていないことです。certifiのMPL-2.0を同梱してよいかは、私にはまだ決められません。本文を読む必要があります。

それでも、4件が空に見えた最初の版よりは、人の読む場所が絞れました。十分でしょうか。まだ足りません。次は、wheel(配布用のzip形式のファイル)にライセンス本文が入っているかを数える検査を足してみます。

参照した一次情報

この記事をシェア