Don't fear Python subprocess or Go codegen

Ben Hoyt

PythonのsubprocessもGoのコード生成も恐れることはない

原文は Ben Hoyt により に公開されました。 このブログを購読する

Jubilantは、Canonicalが手がけるデプロイ・運用ツールJujuのために私が作成したPython APIです。Jubilant自体は非常にシンプルですが、この記事では他の開発者のみなさんにも興味を持っていただけるかもしれない、いくつかの設計上の選択について紹介します。Pythonのsubprocess.runの活用、Goの構造体からPythonのdataclassを生成するコード生成、そしてMakeとuvの活用です。

普段このサイトで仕事のことを直接書くことはあまりないのですが、たまにはいいでしょう。Canonicalで作っているもののほとんどはオープンソースで、Jubilantも例外ではありません。

おまけに、私はこの名前にまさにjubilantな気分です――名付け親は同僚のDave Wildingです。

Subprocess.run

Jubilantは、subprocess.runを使ってjujuコマンドを呼び出すPython APIです。大幅に簡略化した例をご紹介します。

def deploy(app: str):
    subprocess.run(['juju', 'deploy', app])

そんなことはするなと教わってきたのではないでしょうか。ひどいアイデアなのではないでしょうか。

思っているほどひどくはありません。私たちの場合、以前のPython APIであるpython-libjujuよりもシンプルで安定していることがわかりました。古いライブラリは、カスタムRPCやWebSocket、非同期に更新されるデータ構造、Pythonのasyncawait、そして巨大なAPIサーフェスを備えた複雑なJuju APIを呼び出すものでした。使うのも保守するのも楽しいものではありませんでした。

さらに、Juju CLIの操作のほとんどは本質的に非同期なので、asyncioの複雑さは必要ありませんでした。たとえばjuju deploy myappはすぐにユーザーに制御を返し、Jujuコントローラーがバックグラウンドでアプリをデプロイします。

でも、新しいプロセスを立ち上げるのはオーバーヘッドが大きいのではないでしょうか。このユースケースでは比較的小さく済みます(特にLinuxではプロセスの生成は高速です)。deployコマンド自体が1〜2秒かかるため、そこに数ミリ秒が加わっても大した問題にはなりません。

安定性はどうでしょうか。それは確かに懸念でした。しかしJujuチームはメジャーバージョン内でのCLIの安定性を保証しています。コマンドライン引数が変更されることはありません。デフォルトのテキスト出力が変わることはありますが、Jubilantが使用しているJSON出力フォーマット(--format json)が壊されることはありません。

もちろん、Jubilantがpython-libjujuのすべての用途を置き換えるわけではありません。何かをストリームしたりイベントを購読したい場合には対応できません。ただ、python-libjujuは主にJujuのオペレーター(「charm」と呼ばれます)の統合テストに使われており、その用途ではJubilantは非常にうまく機能します。

したがって、複雑なAPIとシンプルなCLIを持つツールであれば、CLIをラップすることが最適な方法かもしれません。少なくとも私たちにとっては、確実にうまく機能しています。

このアプローチでの単体テスト

たとえば、versionメソッド(juju versionを実行して出力をパースするもの)をテストしたいとしましょう。テスト対象のコードは次のようになっています。

def version(self) -> Version:
    # self.cli() is a helper that calls subprocess.run
    stdout = self.cli('version', '--format', 'json', '--all',
                      include_model=False)
    version_dict = json.loads(stdout)
    return Version._from_dict(version_dict)

テストでは、subprocess.runをモックしたものを使います。汎用的なMagicMockよりも使いやすい、自作の小さなモックを用意しました。

以下はPytestを使った単体テストの例です。test_version.pyからの抜粋です。

def test_simple(run: mocks.Run):
    version_dict = {
        'version': '3.6.11-genericlinux-amd64',
        'git-commit': '17876b918429f0063380cdf07dc47f98a890778b',
    }
    run.handle(['juju', 'version', '--format', 'json', '--all'],
               stdout=json.dumps(version_dict))

    juju = jubilant.Juju()
    version = juju.version()

    assert version == jubilant.Version(
        3, 6, 11,
        release='genericlinux',
        arch='amd64',
        git_commit='17876b918429f0063380cdf07dc47f98a890778b',
    )
    assert version.tuple == (3, 6, 11)

run.handleの呼び出しは、モックに対して「これらのCLI引数で呼ばれたら、指定された出力を返してください」と指示しています。

Pythonらしく、型付けされたラッパー

Jujuの管理者はすでにJuju CLIに慣れているため、JubilantもCLIのように感じられつつ、Pythonらしいものにしたいと考えました。コマンド名や引数名を含め、CLIコマンドを1対1でラップすることは、私たちの設計目標の一つでした。

たとえば、管理者は次のようなコマンドを実行することに慣れています。

juju deploy webapp
juju deploy mysql --config cluster-name=testclust
juju integrate webapp mysql

これはPythonでは次のように直接書き換えることができます。

juju = jubilant.Juju()

juju.deploy('webapp')
juju.deploy('mysql', config={'cluster-name': 'testclust'})
juju.integrate('webapp', 'mysql')

CLIの位置引数はPythonでは位置引数に、--configのようなフラグはキーワード引数になります。そしてcluster-name=testclustのようなキーと値のペアといったリッチなオプションは、辞書のような適切なPythonの型になります。

deployメソッドは次のように定義されています。

def deploy(
    self,
    charm: str | pathlib.Path,
    app: str | None = None,
    *,  # this makes the rest of the arguments keyword-only
    attach_storage: str | Iterable[str] | None = None,
    base: str | None = None,
    bind: Mapping[str, str] | str | None = None,
    channel: str | None = None,
    config: Mapping[str, ConfigValue] | None = None,
    # ...
) -> None:

型アノテーションは優れたドキュメントになります(たとえばdeploy)が、IDEでのJubilantの使い心地も格段に向上させます。引数名の素晴らしいオートコンプリートや、どの型を使うべきかのヒントが得られます。

Jubilantの型チェックにはPyrightを厳格モードで使っています。単体テストや統合テストも含めてチェックしており、ライブラリのユーザーにとって型が理にかなっていることを保証しています。

Jujuの一部のCLIコマンドはオーバーロードされています。たとえばjuju config myappは引数なしではアプリの設定を取得しますが、juju config myapp foo=bar baz=42のように引数を渡すと設定を設定します。このようなケースにはPythonの@overloadデコレーターを使っています。

ConfigValue = bool | int | float | str

# Get configuration values (return them)
@overload
def config(self, app: str) -> Mapping[str, ConfigValue]: ...

# Set configuration values
@overload
def config(
    self,
    app: str,
    values: Mapping[str, ConfigValue],
    *,
    reset: Iterable[str] = (),
) -> None: ...

# Only reset values
@overload
def config(self, app: str, *, reset: Iterable[str]) -> None: ...

# The definition itself (no @overload)
def config(
    self,
    app: str,
    values: Mapping[str, ConfigValue] | None = None,
    *,
    reset: Iterable[str] = (),
) -> Mapping[str, ConfigValue] | None:
    # actual implementation here

オーバーロードは、config()を次のいずれかの方法でのみ呼び出すことが許されていることを型チェッカーに伝えます。

# Get configuration values
config = juju.config('myapp')
assert config['foo'] == 'bar'

# Set configuration values
juju.config('myapp', {'foo': 'bar', 'baz': 42})

# Only reset values
juju.config('myapp', reset=['foo', 'baz'])

GoでPythonのdataclassを生成する

Juju CLIのコマンドの中にはデータを返すものがあります。たとえばjuju statusです。デフォルトでは、このコマンドは人間が読めるテキスト出力を返します。例えば次のようになります。

$ juju status
Model  Controller           Cloud/Region         Version  SLA          Timestamp
tt     localhost-localhost  localhost/localhost  3.6.11   unsupported  15:13:39+13:00

Model "admin/tt" is empty.

しかし、出力を返すほぼすべてのJujuコマンドでは、JSONやYAMLでの出力を要求できます。たとえば(jqでJSONを整形して表示すると)次のようになります。

$ juju status --format json | jq
{
  "model": {
    "name": "test",
    "type": "iaas",
    "controller": "localhost-localhost",
    "cloud": "localhost",
    "region": "localhost",
    "version": "3.6.11",
    "model-status": {
      "current": "available",
      "since": "18 Nov 2025 11:06:43+13:00"
    },
    "sla": "unsupported"
  },
  "machines": {},
  "applications": {},
  "storage": {},
  "controller": {
    "timestamp": "15:14:15+13:00"
  }
}

Jubilantは--format jsonを使い、その出力を一連のstatus dataclassにパースします。statusメソッドが返すトップレベルのものは単にStatusと呼ばれています。各クラスは辞書(JSON由来)からインスタンスを生成するための_from_dictメソッドを持っています。例えば次のようになります。

@dataclasses.dataclass(frozen=True)
class Status:
    model: ModelStatus
    machines: dict[str, MachineStatus]
    apps: dict[str, AppStatus]
    # ...

    @classmethod
    def _from_dict(cls, d: dict[str, Any]) -> Status:
        return cls(
            model=ModelStatus._from_dict(d['model']),
            machines={k: MachineStatus._from_dict(v)
                      for k, v in d['machines'].items()},
            apps={k: AppStatus._from_dict(v)
                  for k, v in d['applications'].items()},
            # ...
        )

しかしStatusオブジェクトは巨大です。28もの異なるクラスから構成されており、それぞれがいくつかのフィールドを持ち、型もさまざまです。通常はstrint、あるいは値が別のdataclassのインスタンスであるdictになります。

これらの信頼できる情報源(source of truth)は、Jujuのコードベースにある多数のGo構造体です。たとえば上記のStatusクラスは、JujuのformattedStatus構造体に対応しています。

type formattedStatus struct {
    Model        modelStatus                  `json:"model"`
    Machines     map[string]machineStatus     `json:"machines"`
    Applications map[string]applicationStatus `json:"applications"`
    // ...
}

ミスを避けるため、Pythonのdataclassを手書きしたくはありませんでした。そこで、ランタイムリフレクションを使ってPythonコードを吐き出す、Goで書かれたシンプルなコードジェネレーターを作成しました。

その中核は、与えられた構造体からフィールド情報のマップを埋める再帰関数です。その一端をご紹介します。

func getFields(t reflect.Type, m map[string][]FieldInfo, typeName string, level int) {
    if _, ok := m[typeName]; ok {
        return
    }
    // ...
    if t.Kind() != reflect.Struct {
        return
    }
    m[typeName] = nil
    var result []FieldInfo
    for i := 0; i < t.NumField(); i++ {
        field := t.Field(i)
        jsonTag := field.Tag.Get("json")
        if jsonTag == "" {
            jsonTag = field.Name
        }
        tagFields := strings.Split(jsonTag, ",")
        jsonField := tagFields[0]
        if jsonField == "-" {
            jsonField = ""
        }
        fieldType := field.Type.String()
        niceName := getNiceName(fieldType)
        result = append(result, FieldInfo{
            Name:      field.Name,
            Type:      niceName,
            JSONField: jsonField,
            Pointer:   fieldType[0] == '*',
            OmitEmpty: slices.Contains(tagFields[1:], "omitempty"),
        })
        if jsonField == "" {
            continue
        }
        switch field.Type.Kind() {
        case reflect.Struct:
            getFields(field.Type, m, niceName, level+1)
        case reflect.Map:
            elemType := field.Type.Elem()
            niceElemName := getNiceName(elemType.String())
            getFields(elemType, m, niceElemName, level+1)
        case reflect.Slice:
            elemType := field.Type.Elem()
            niceElemName := getNiceName(elemType.String())
            getFields(elemType, m, niceElemName, level+1)
        case reflect.Pointer:
            elemType := field.Type.Elem()
            niceElemName := getNiceName(elemType.String())
            getFields(elemType, m, niceElemName, level+1)
        }
    }
    m[typeName] = result
}

一度だけ実行して(あとはPythonのdataclassを直接保守する)つもりだったので、決して高品質なコードではありません。しかし、必要なことは果たしてくれました。dataclassやフィールド、_from_dictメソッドが詰まった巨大なPythonファイルを生成し、それらがタイプミスなく情報源と完全に一致していることが保証されました。

教訓は何でしょうか。小さな使い捨てのプログラムを書いて、ある言語のデータ構造を別の言語に変換することを恐れないことです。信頼できる情報源は、何か過剰に設計されたスキーマ言語である必要はありません。Goの構造体で十分です。

Makeとuv

Jubilantについてもう一つ強調したいのが、開発ツールです。Astralのuvをプロジェクトで使うのは今回が初めてでしたが、素晴らしいものでした。Pythonの依存関係管理の悩みを本当に解決してくれました。

プロジェクトの設定はすべてpyproject.tomlにまとめており、ライブラリの依存関係(Jubilantの唯一の依存はPyYAMLです)や開発用の依存関係(Pyright、Pytest、Ruffなど)も含まれています。

また、uvが独自のものを提供するまでの間、コマンドランナーとして使う非常にシンプルなMakefileも用意しています。uvが独自のものを提供するまでの間の暫定的なものです。Justのような代替手段があるのは承知していますが、癖はあるものの、どこにでもインストールされている50年もののプログラムを使うのが気に入っています。

以下はMakefileの抜粋で、最もよく使うコマンドを示しています。

# We're using Make as a command runner, so always make
# (avoids need for .PHONY)
MAKEFLAGS += --always-make

help:  # Display help
	@echo "Usage: make [target] [ARGS='additional args']\n\nTargets:"
	@awk -F'#' '/^[a-z-]+:/ { sub(":.*", "", $$1); print " ", $$1, " #", $$2 }' Makefile | column -t -s '#'

all: format lint unit  # Run all quick, local commands

docs:  # Build documentation
	MAKEFLAGS='' $(MAKE) -C docs run

format:  # Format the Python code
	uv run ruff format

lint:  # Perform linting and static type checks
	uv run ruff check
	uv run ruff format --diff
	uv run pyright

unit:  # Run unit tests, eg: make unit ARGS='tests/unit/test_deploy.py'
	uv run pytest tests/unit -vv --cov=jubilant $(ARGS)

「help」ターゲット内のちょっと変わったawkコマンドにより、make helpと入力するだけでコマンドの一覧とその説明を取得できます。たとえば次のようになります。

$ make help
Usage: make [target] [ARGS='additional args']

Targets:
  help      Display help
  all       Run all quick, local commands
  docs      Build documentation
  format    Format the Python code
  lint      Perform linting and static type checks
  unit      Run unit tests, eg: make unit ARGS='tests/unit/test_deploy.py'

このMakefileのおかげで、私の開発サイクルは、コードを書いてmake allと入力してリントとテストのパスを確認し、PRをプッシュするだけになっています。

おわりに

Pythonから操作したい大きなツールがあるなら、次のうちの一つ以上を検討してみてはいかがでしょうか。

  • 思い切ってsubprocess.runでラップしてみる
  • コピーミスを避けるためにコードジェネレーターを書く
  • Makeとuvを使ってみる

とにかくシンプルに、そして2025年のクリスマスを楽しんでください!

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。

コメント