[Python][Gradle] Java と Python の混在プロジェクトでパッケージバージョンを統一する


とある Java/Python 混在プロダクトで、パッケージ作成時のバージョン設定をまとめた話。

プロダクトは Java 主体なのでビルド設定は Gradle を使用し、成果物として以下を作っています。

- Java → .jar ファイル
- Python → .whl パッケージ

Gradle の設定は通常通り、`build.gradle` があって、バージョン値は `gradle.properties` に置いているとします。

```gradle.properties
version=1.0.0
```
これに Python のパッケージバージョンを連動させるようにします。

- バージョン値の設定は一箇所に集約する
- 全ビルド時はプロダクト全体でバージョンを統一する
- 部分ビルドで Python パッケージだけのビルドもしたい


## Python パッケージ

Python パッケージは setuptools を使い、`setup.py` というスクリプトを用意して、以下のコマンドを実行すると .whl ファイルが作られます。
スクリプトは setuptools.setup() メソッドにパッケージ情報を渡すだけです。

```
$ python setup.py bdist_wheel
```

```setup.py
# -*- coding: utf-8 -*-
import setuptools

d = setuptools.setup(
    name='sample-prj',
    version='1.0.0',
    author='Foo',
    author_email='foo@example.com',
    url='https://www.example.com',
    description='Sample project',
    packages=setuptools.find_packages()
)
```

デフォルトでは `setup.py` のパッケージ情報としてバージョン値を直接記述することになりますが、これを何らかの方法で外から指定できるようにします。
`setup.py` と同じディレクトリに `setup.cfg` というファイルを置いて設定を外部化する方法があり、以下の 2通りの記述方法がありました。

```setup.cfg
[metadata]
version = 1.0.0
```

```setup.cfg
[metadata]
version = file: VERSION
```

ビルド時にバージョン値を自動で渡したいので、後者の `VERSION` ファイルにバージョン値を書く方法を使いたいと思います。

なお、`setup.cfg` には他のパッケージ情報をまとめて記述することができるので、setuptools.setup() に指定していた情報をすべて外部化できます。
(ここでは簡単のためバージョン値のみを扱います)

ただし、`file:XXX` を使って設定値を外部ファイルから取得する記述は setuptools ライブラリが古いとサポートされないようなので、新しいものにしておきましょう。
私が試したのは **52.0.0** です。


## 部分ビルド

`setup.cfg` へ外部ファイルでバージョン値を指定する形で、以下のように変更しました。

```構成
sample/
  setup.py
  setup.cfg
  VERSION
  ...
```

```setup.cfg
[metadata]
version = file: VERSION
```

VERSION ファイル

```
1.0.0
```

```setup.py
# -*- coding: utf-8 -*-
import os
import setuptools

if not os.path.isfile("VERSION"):
    with open("VERSION", "w") as f:
        f.write("0.0.1")

d = setuptools.setup(
    name='sample-prj',
    author='Foo',
    author_email='foo@example.com',
    url='https://www.example.com',
    description='Sample project',
    packages=setuptools.find_packages()
)
```

`setup.py` の冒頭で `VERSION` ファイルの有無を確認し、なければデフォルトで作るようにしています。
部分ビルドでとりあえずパッケージを作りたいときには、デフォルト値で作られます。

全ビルドのときは、外部から `VERSION` ファイルを作ってから `setup.py` を実行するようにしてバージョン値を連携することができます。

git でバージョン管理する場合は `VERSION` ファイルは含めないようにします。

```.gitignore
*.egg-info/
VERSION
```


## 全ビルド

gradle の全ビルドに Python のパッケージ作成を組み込みます。

`setup.py` を実行するスクリプトをタスクとして追加します。

```
root/
  build.gradle
  gradle.properties       # バージョン値を設定
  make_wheel.py           # サブプロジェクトの setup.py を実行する
    :
sample/                   # Python のサブプロジェクト
  setup.py
  setup.cfg
  VERSION
    :
```

```gradle.properties
version=1.0.0
```

```build.gradle
  :
task make_wheel(type:Exec) {
    workingDir '.'
    commandLine 'python3', 'make_wheel.py', getProperty('version')
}

tasks.installDist.dependsOn make_wheel

distributions {
    main {
        baseName = 'sample-package'
        contents {
              :
            into('wheel') {
                from 'build/wheel'
            }
              :
        }
    }
}
```

パッケージ作成スクリプトにバージョン値を引数で渡し、作成した wheel パッケージを build/wheel/*.whl として収集する形にします。
`make_wheel.py` はこんな感じ。

```make_wheel.py
import os
import sys
import subprocess
import shutil

# 引数からバージョン値を取得
pkg_version = sys.argv[1]

# バージョン値を Java形式→Python形式へ変換
_vers = pkg_version.split('-')
if len(_vers) > 1:
    if _vers[1] == 'SNAPSHOT':
        pkg_version = _vers[0] + 'rc1'
    else:
        pkg_version = _vers[0] + 'a1'

# サブプロジェクトで setup.py を実行する
def run_setup(dir, task):
    subprocess.call('python3 setup.py ' + task, shell=True, cwd=dir)

INSTALL_DIR = os.path.join('build', 'wheel')
TARGET_DIRS = ['../sample']

# ビルド結果を配置するディレクトリ作成
if os.path.exists(INSTALL_DIR):
    shutil.rmtree(INSTALL_DIR)
os.makedirs(INSTALL_DIR)

# Python のサブプロジェクトに対して setup.py を実行する
for d in TARGET_DIRS:
    # VERSION ファイルを作成
    with open(os.path.join(d, 'VERSION'), 'w') as f:
            f.write(pkg_version)
    setup(d, 'bdist_wheel')
```

サブプロジェクトで作成した wheel パッケージを全体で集めたいので、`setup.py` の bdist_wheel 実行時に .whl ファイルを INSTALL_DIR へコピーするようにしておきます。

`setup.py` の末尾に以下を追加

```setup.py
  :
d = setuptools.setup(...)

INSTALL_DIR = '../root/build/wheel'  # プロジェクト構成、build.gradle、make_wheel.py と整合させておく

if os.path.exists(SDK_DIR):
    for t, v, p in d.dist_files:
        print(t, v, p)
        if t == 'bdist_wheel':
            shutil.copy2(p, os.path.join(INSTALL_DIR, os.path.basename(p)))
```

`d.dist_files` は以下の値を返すようです。

```
t : bdist_wheel
v : 3.6
p : dist/sample-1.0.0-py3-none-any.whl
```

これで、全体の gradle ビルド時に Python のパッケージが作成され、成果物が収集されるようになります。



ちなみに、Java のパッケージには `1.0.0-SNAPSHOT` のような値を指定したいことがありますが、これがそのまま指定されると Python 側でエラーになりました。

Python 側のバージョンは PEP 400 というルールがあるようです。

https://www.python.org/dev/peps/pep-0440/

今回の例では、`make_wheel.py` の先頭で受け取った Java 形式のバージョンを Python 形式へ変換するようにしました。

`1.0.0-SNAPSHOT` の場合は `1.0.0rc1` とし、その他の `1.0.0-xxx` だった場合は α版の `1.0.0a1` としました。
ここはプロジェクトの要件次第かと思います。

```make_wheel.py
# バージョン値を Java形式→Python形式へ変換
_vers = pkg_version.split('-')
if len(_vers) > 1:
    if _vers[1] == 'SNAPSHOT':
        pkg_version = _vers[0] + 'rc1'
    else:
        pkg_version = _vers[0] + 'a1'
```

// EOF