PyPIにアップロード時のエラー「The name '???' is too similar to an existing project.」に泣かないために

# 背景

先日、以下の記事を参考に、初めてPyPIに[自作ライブラリ](https://pypi.org/project/iilog-pckg/)を登録しました。

https://qiita.com/c60evaporator/items/e1ecccab07a607487dcf

しかしその途中、思いがけないトラブルに見舞われました。諸々を済ませていざPyPIへのアップロードを試みたところ、以下のエラーが発生したのです。

```shell
$ python -m twine upload --repository pypi dist/iilog-0.0.4*
Uploading distributions to https://upload.pypi.org/legacy/
Uploading iilog-0.0.4-py3-none-any.whl
100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 22.4/22.4 kB • 00:00 • 886.2 kB/s
WARNING  Error during upload. Retry with the --verbose option for more details.
ERROR    HTTPError: 400 Bad Request from https://upload.pypi.org/legacy/
         The name 'iilog' is too similar to an existing project. See https://pypi.org/help/#project-name for more information.
```

なんと、「名前が酷似しているプロジェクトがすでにある」とのこと。考えたパッケージ名が予約済みでないことは[PyPIのサイトで検索](https://pypi.org/search/)して入念に確認していたので、想定外の出来事にたいへん悲痛な思いをしました。ロゴまで（簡単ながら）作っていたので、中のライブラリの名前はそのままにパッケージ名のみを変更することで泣く泣く対処しました。

しかし、こんな悲劇は二度と味わうものかと、対策のためのPythonプログラムを書くことにしました。そしてこの記事を目にされた、一人でも多くの方を涙から救えれば幸いです。

# Pythonプログラム

さっそく、書いた対策用のプログラムを載せます。なお、[Beautiful Soup](https://www.crummy.com/software/BeautifulSoup/bs4/doc/) が必要となります（`$ pip install beautifulsoup4`）。

```python
from urllib.request import urlopen
from bs4 import BeautifulSoup
from re import sub

def normalize(name: str) -> str:
  return sub(
    r'(\.|_|-)', '', sub(
      '(o|O)', '0', sub(
        '(l|L|i|I)', '1', name
      )
    )
  ).lower()

def search_similar(package_name: str) -> list:
  normalized_name = normalize(package_name)
  with urlopen('https://pypi.org/simple/') as f:
    soup = BeautifulSoup(f, 'html.parser')
  packages = [
    {'name': a.text,
     'links_url': f'https://pypi.org/simple/{a.text}',
     'project_url': f'https://pypi.org/project/{a.text}/'}
    for a in soup.find_all('a')
    if normalized_name == normalize(a.text)
  ]
  return packages

if __name__ == '__main__':
  packages = search_similar('iilog')

  from pprint import pprint
  pprint(packages)
```

ここで定義している `search_similar()` を使うことで、名前の酷似するパッケージがあるか確認できます。この関数に自作パッケージに付けようとしている名前を渡せば、名前の似ているパッケージを探して来てくれます。

実引数は適宜変更していただきたいのですが、このまま実行すると次のように出力されます。

```shell
[{'links_url': 'https://pypi.org/simple/L1log',
  'name': 'L1log',
  'project_url': 'https://pypi.org/project/L1log/'}]
```

このように、すでに似た名前のパッケージがあるため、`iilog` という名前のパッケージは登録できないと判断できます。  

さて、このプログラムの中身について簡単に説明します。

### PyPI APIでパッケージ一覧を取得

PyPIのAPIには [Legacy API](https://warehouse.pypa.io/api-reference/legacy.html) というHTMLを返すAPI、 [JSON API](https://warehouse.pypa.io/api-reference/json.html) の2つがあり、パッケージの一覧を取得するには Legacy API を使います。https://pypi.org/simple/ というエンドポイントにアクセスすることで、全パッケージの名前とリンクの記載されたHTMLデータを取得できます。

先のPythonプログラムでは、こうして取得したHTMLデータを Beautiful Soup によってパースすることで、各パッケージの名前を文字列として取得しています。

### `normalize()` によるパッケージ名の正規化

以下の質問回答によると、PyPIではパッケージ名に特殊な正規化を施すことで、名前の類似性を判断しているようです。

https://stackoverflow.com/questions/71489687/pypi-the-name-is-too-similar-to-an-existing-project#answer-71490005

この回答者の方いわく、表題のエラーメッセージは `ultranormalize_name()` というユーザー定義データベース関数が原因であることは確かでありながら、[関数定義らしきスクリプト](https://github.com/pypa/warehouse/blob/ff5bba2906a666a2db551ce341cba27a87a7b8ed/warehouse/migrations/versions/d18d443f89f0_ultranormalize_name_function.py#L29)を根拠に**あくまで推測**の限り、この関数は `I` と `l` のような見間違いやすい文字を同一文字に置換するなどして文字列を正規化する関数であるようです。  
（2023年7月4日追記：関数らしきスクリプトのURLは、質問回答時のものです。現在のスクリプトを見るには[この関数名をPyPIのリポジトリで検索](https://github.com/search?q=repo%3Apypi%2Fwarehouse+ultranormalize_name&type=code)してください。なお、今日現在のリンクは[コチラ](https://github.com/pypi/warehouse/blob/26c0e33188759a1103fdcb096e0026a0a38566ff/warehouse/migrations/versions/d18d443f89f0_ultranormalize_name_function.py#L29)です。）

というわけで今回はこの推測が正しいと仮定して、スクリプトの通りの関数を `normalize()` として定義しました。

（とすると、当初のパッケージ名 `iilog` にはいわゆる「紛らわしい文字」が4文字も含まれていたということになります。そりゃ引っかかるわけだ。）

### 正規化したパッケージ名どうしの比較

あとは、自分で入力したパッケージ名と、パッケージ一覧から得たパッケージ名をたがいに正規化して比較し、一致したものをリストとして返しています。リストの要素は、「パッケージ名」「ライブラリのリンク集へのURL」「プロジェクトページへのURL」を属性にもつ辞書です。

# おわりに

PyPIにライブラリを登録する際の `The name '???' is too similar to an existing project.` というエラーを未然に防ぐためのPythonプログラムを書きました。

[あくまでも推測の部分がある](https://qiita.com/ky5bass/items/2cd18f072eb8cee0775a#normalize-によるパッケージ名の正規化)ため、これを利用しても完全には対策できないのかもしれませんが、少なくとも確率は上がりそうです。