環境に合わせたpip downloadコマンドを自動生成する

# What's？

pipを使ってモジュールインストールを行う際に、その環境がオフラインだったりするとちょっと困ります。

そうなると、`pip download`でモジュールを事前にダウンロードしておいて、実際の環境ではローカルインストールを行うことになるわけですが。

[Installing from local packages](https://pip.pypa.io/en/stable/user_guide/#installing-from-local-packages)

このダウンロードを行うのがなかなか面倒だったので、pipコマンドの結果から自動生成するようにしてみました、という話です。

# pip download

`pip download`を使うと、Pythonのモジュールをローカルにダウンロードできます。

https://pip.pypa.io/en/stable/cli/pip_download/

できるのですが、特になにも指定せず実行すると「pipを実行した環境で選ぶべきモジュール」を選定します。  
この場合、wheelのようにパッケージにプラットフォームの情報が含まれるようなものは、`pip download`を実行する環境と`pip install`を実行する環境が異なる場合は困ったことになります。

この情報は、`pip debug`で確認することができます。

```shell
$ pip3.8 debug
WARNING: This command is only meant for debugging. Do not use this with automation for parsing and getting these details, since the output and options of this command may change without notice.
pip version: pip 19.3.1 from /usr/lib/python3.8/site-packages/pip (python 3.8)
sys.version: 3.8.3 (default, Aug 31 2020, 16:03:14) 
[GCC 8.3.1 20191121 (Red Hat 8.3.1-5)]
sys.executable: /usr/bin/python3.8
sys.getdefaultencoding: utf-8
sys.getfilesystemencoding: utf-8
locale.getpreferredencoding: UTF-8
sys.platform: linux
sys.implementation:
  name: cpython
Compatible tags: 52
  cp38-cp38-manylinux2014_x86_64
  cp38-cp38-manylinux2010_x86_64
  cp38-cp38-manylinux1_x86_64
  cp38-cp38-linux_x86_64
  cp38-abi3-manylinux2014_x86_64
  cp38-abi3-manylinux2010_x86_64
  cp38-abi3-manylinux1_x86_64
  cp38-abi3-linux_x86_64
  cp38-none-manylinux2014_x86_64
  cp38-none-manylinux2010_x86_64
  ...
  [First 10 tags shown. Pass --verbose to show all.]
```

ポイントは、`Compatible tags`の部分ですね。

```shell
Compatible tags: 52
  cp38-cp38-manylinux2014_x86_64
  cp38-cp38-manylinux2010_x86_64
  cp38-cp38-manylinux1_x86_64
  cp38-cp38-linux_x86_64
  cp38-abi3-manylinux2014_x86_64
  cp38-abi3-manylinux2010_x86_64
  cp38-abi3-manylinux1_x86_64
  cp38-abi3-linux_x86_64
  cp38-none-manylinux2014_x86_64
  cp38-none-manylinux2010_x86_64
  ...
  [First 10 tags shown. Pass --verbose to show all.]
```

省略されている分は、`-v`オプションを付与することで出力できます。  
※大量に出力されるので、載せませんが

```shell
$ pip3.8 debug -v
```

今回は、この結果を使って`pip download`でプラットフォームやPythonのバージョンを指定するようなコマンドを自動生成してみたいと思います。

注意点としては、`pip debug`の出力にあるように、この結果を使うこと自体はオススメされていないということですね。

```shell
WARNING: This command is only meant for debugging. Do not use this with automation for parsing and getting these details, since the output and options of this command may change without notice.
```

では、進めていきましょう。

# 環境

今回の環境は、こちら。CentOS 8.3を使います。

```shell
$ cat /etc/redhat-release
CentOS Linux release 8.3.2011


$ uname -srvmpio
Linux 4.18.0-240.22.1.el8_3.x86_64 #1 SMP Thu Apr 8 19:01:30 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux
```

Pythonは、3.8をインストールします。

```shell
$ sudo dnf install python3.8
```

バージョン。

```shell
$ python3.8 -V
Python 3.8.3


$ pip3.8 -V
pip 19.3.1 from /usr/lib/python3.8/site-packages/pip (python 3.8)
```

このインストール方法だと、`python`コマンドは`pytyhon3.8`に、`pip`コマンドは`pip3.8`となります。

# 考え方

Pythonのモジュールをダウンロードする際には、現在はwheelとソース配布物を主に見れば良さそうです。

https://wheel.readthedocs.io/en/stable/

https://docs.python.org/ja/3.8/distributing/index.html

`pip download`で`--platform`とか`--python-version`を指定する場合は、この両者を分けて考える必要がありそうです。

# ソース配布物の場合

ソース配布物をダウンロードする場合は、以下のコマンド固定で良いでしょう。

```shell
$ pip3.8 download -d lib \
        --no-binary=:all: \
        --no-deps \
        -r requirements.txt
```

ここで、`requirements.txt`にはソース配布物のみのモジュール名が含まれているものとします。

`pip debug`の結果を使うのは、wheelの方です。

# wheel

pipでのモジュールインストール先に合った、wheel形式のモジュールをダウンロードするためのコマンドを生成するスクリプトは、こちら。

```python:print_pip_download_wheels.py
import re
import subprocess

download_dir = 'lib'
pip_command = 'pip3.8'

pip_debug = subprocess.getoutput(f'{pip_command} debug -v')

tags = re.split(r'Compatible tags: .+\r?\n', pip_debug)[1]
tags = re.sub(r' +', '', tags)

python_versions = set()
implementations = set()
abis = set()
platforms = set()

for tag in re.split(r'\r?\n', tags):
    python_version_implementation, abi, platform = re.split(r'-', tag)

    m = re.search(r'(?P<implementation>[a-z]+)(?P<python_version>\d+)', python_version_implementation)

    python_versions.add(m.group('python_version'))
    implementations.add(m.group('implementation'))
    abis.add(abi)
    platforms.add(platform)

print(f'''{pip_command} download -d {download_dir} \\
        --only-binary=:all: \\
        --no-deps \\'''
)

## platform
platform_list = sorted(list(platforms))

if 'any' in platform_list:
    print(f'        --platform any \\')
    platform_list.remove('any')

for platform in platform_list:
    print(f'        --platform {platform} \\')

## python-version
for python_version in sorted(list(python_versions)):
    print(f'        --python-version {python_version} \\')

## implementation
implementation_list = sorted(list(implementations))

if 'py' in implementation_list:
    print(f'        --implementation py \\')
    implementation_list.remove('py')

for implementation in implementation_list:
    print(f'        --implementation {implementation} \\')

## abi
abi_list = sorted(list(abis))

if 'none' in abi_list:
    print(f'        --abi none \\')
    abi_list.remove('none')

for abi in abi_list:
    print(f'        --abi {abi} \\')

print('        -r requirements.txt')
```

`pip`コマンドは、`pip3.8`に固定していますが、環境に合わせて変更を…。

このスクリプトを、**`pip install`を実行する環境と同じ条件の環境**で実行すると、以下のような結果が得られます。

```shell
$ python3.8 print_pip_download_wheels.py
pip3.8 download -d lib \
        --only-binary=:all: \
        --no-deps \
        --platform any \
        --platform linux_x86_64 \
        --platform manylinux1_x86_64 \
        --platform manylinux2010_x86_64 \
        --platform manylinux2014_x86_64 \
        --python-version 3 \
        --python-version 30 \
        --python-version 31 \
        --python-version 32 \
        --python-version 33 \
        --python-version 34 \
        --python-version 35 \
        --python-version 36 \
        --python-version 37 \
        --python-version 38 \
        --implementation py \
        --implementation cp \
        --abi none \
        --abi abi3 \
        --abi cp38 \
        -r requirements.txt
```

ここでの`requirements.txt`はwheel形式のもののみが含まれている前提にしています。

あとは、これをシェルスクリプトにでも保存して実行すればよいでしょう。

`pip debug`の結果は、実行する環境によって変わるので、実際に`pip install`を使う環境と同じものを使って確認するべきですね。

# ポイント

どうも、`--implementation`では`py`を、`--api`では`none`を先に持ってきた方が良さそうです。

あと、ソース配布物、wheelともに`--no-deps`を入れているのは、モジュールの依存先に逆パターン（ソース配布物がwheelに依存する、wheelがソース配布物に依存する）があると`pip download`コマンドの設定と矛盾してダウンロードに失敗するからです。

よって、ダウンロード結果から`pip install`する時も、やはり`--no-deps`を付けた方が良いですね。

```shell
# wheel
$ pip3.8 install --no-index --find-links=lib --no-deps -r requirements-wheels.txt

# ソース配布物
$ pip3.8 install --no-index --find-links=lib --no-deps -r requirements-sdists.txt
```

また、wheelを先に`pip install`しておかないと、ソース配布物にwheelへの依存が含まれていた場合に依存するwheelを取得しようとしてやっぱり困ったことになります。

というわけで、このやり方を使う場合は、`requirements.txt`はwheelとソース配布物で分けた方がいいでしょうね…。

ふだんの`pip install`でこういう悩みに合わないのは、`pip install -v`などで挙動を見ていると、リポジトリが持っているモジュールのリストから自分がどれを選ぶべきかをpip側で確認しているから、な気がしますね。

今回はpipコマンド側から絞って問い合わせに行っている形になるので、通常の使い方とは異なっているのかな、と。