Don't fear Python subprocess or Go codegen

Ben Hoyt

別怕 Python subprocess 與 Go 程式碼產生

原文由 Ben Hoyt 發布,訂閱此部落格

Jubilant 是我為 Juju 打造的 Python API,JujuCanonical 所開發的部署與維運工具。Jubilant 本身非常簡單,但本文要分享幾個或許會讓其他開發者感興趣的設計抉擇:使用 Python 的 subprocess.run、從 Go struct 產生 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 更簡單、也更穩定。舊的函式庫會呼叫複雜的 Juju API,包含了客製化的 RPC、websockets、非同步更新的資料結構、Python 的 asyncawait,以及龐大的 API 介面。用起來、維護起來都不輕鬆。

此外,大多數 Juju CLI 操作本質上就是非同步的,所以 asyncio 的複雜性其實沒有必要。舉例來說,juju deploy myapp 會很快就回應使用者,而 Juju controller 會在背景部署你的應用程式。

但產生一個新行程難道不會有很大的 overhead 嗎?以這個使用情境來說,其實還好(尤其是在 Linux 上,產生新行程的速度很快)。deploy 指令本身可能就要花上一兩秒,所以多加幾毫秒根本不算什麼。

那穩定性呢?這確實是個實際的顧慮。不過 Juju 團隊承諾在同一個主版本內維持 CLI 的穩定:他們不會更動命令列參數。他們有時會改變預設的文字輸出,但他們不會破壞 JSON 輸出格式,而這正是 Jubilant 所使用的(--format json)。

當然,Jubilant 並無法取代 python-libjuju 的所有用途:如果你想做串流或訂閱事件,那就沒辦法了。不過 python-libjuju 主要用來對 Juju operator(稱為「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 的 mock 版本。我們自己做了一個小型 mock;比起通用的 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 這一行是在告訴 mock:「當以這些 CLI 參數被呼叫時,就回傳給定的輸出」。

具型別的 Pythonic 包裝

Juju 的管理者已經很熟悉 Juju CLI,因此我們希望 Jubilant 用起來像 CLI,但更 Pythonic。這是我們的設計目標之一:一對一地包裝 CLI 指令,包含指令名稱與參數名稱。

舉例來說,管理者習慣執行這樣的指令:

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),同時也讓 Jubilant 在 IDE 中用起來非常愉快:你可以在參數名稱上獲得很棒的自動完成,還有型別提示。

我們使用 Pyright 的嚴格模式來對 Jubilant 做型別檢查。這包含了單元測試與整合測試,確保型別對函式庫的使用者來說是合理的。

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

這些 overload 告訴型別檢查器,你只能用以下其中一種方式呼叫 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。每個類別都有一個 _from_dict 方法,用來從 dict(來自 JSON)建立實例。例如:

@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

這些的源頭是 Juju 程式碼庫中的一堆 Go struct。舉例來說,上面那個 Status 類別對應到 Juju 中 formattedStatus 這個 struct:

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

為了避免出錯,我實在不想手寫這些 Python dataclass。所以我用 Go 寫了一個簡易的程式碼產生器,它利用 執行時反射來吐出 Python 程式碼。

其核心是一個遞迴函式,它會從給定的 struct 中填入欄位資訊的 map。以下是片段讓你感受一下:

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 檔案——而且我們知道它們與源頭完全一致,不會有打錯字的問題。

重點是什麼?別害怕寫些用完即丟的小程式來幫你把資料結構從一種語言轉換到另一種語言。源頭不必是什麼過度設計的 schema 語言;一個 Go struct 就很夠用了。

Make 與 uv

Jubilant 另一項我想強調的是開發者工具。這是我第一次在專案中使用 Astral 的 uv,體驗非常棒。他們真的解決了 Python 依賴管理的痛點。

我們有一個 pyproject.toml,裡面包含了所有專案設定,包括函式庫依賴(Jubilant 唯一的依賴是 PyYAML)以及開發依賴(Pyright、Pytest、Ruff 等)。

我們還有一個極簡的 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 確保通過 lint 且測試都過,然後推送 PR。

結論

如果你有一個想用 Python 來驅動的大型工具,或許可以考慮以下一或多點:

  • 放手一搏,用 subprocess.run 來包裝它
  • 寫個程式碼產生器來避免複製時的錯誤
  • 使用 Make 和 uv!

保持簡單,祝你有個愉快的 2025 年聖誕節!

本文章由 muse-spark-1.2-contributor 進行翻譯

留言