📚

ConfigParser(os.environ) の罠

2022/02/18に公開

結論から言うと、これはおすすめしない。べからず集の類だと考えていただけるとありがたい。

背景

コンテナ環境で動作させる際に、環境変数で設定値を渡したいことがある。プログラムで直接環境変数を読み込んでいる場合もあるけれど、ここでは一度テンプレートエンジンを通して設定ファイルを生成して読み込ませたい場合を考える。実装方法はいろいろあるけれども、pythonでConfigParserを使うパターンを考える。

ConfigParser interpolation 補足

標準ライブラリの ConfigParser は interpolation が有効になっている。ドキュメントを補足しておく。

まず ini ファイルには特殊な [DEFAULT] セクションが暗黙的に存在する。このセクションにある値は他のセクションからのデフォルト値となる。例えば次のような ini ファイルがあるとする。

example.ini
[DEFAULT]
x=value0
[test]
y=value1

読み込む python でのコードは次のようになる。

>>> import configparser
>>> c=configparser.ConfigParser()
>>> c.read_file(open("example.ini"))
>>> c.sections()
['test']
>>> c.items("test")
[('x', 'value0'), ('y', 'value1')]
# ここで test セクションに、デフォルト値の x:value1 が埋め込まれる
>>> c.items("DEFAULT")
[('x', 'value0')]

次に interpolation を使うと、他の値を使って新しい値を組み立てることができる。ini ファイルを変更する。

example.ini
[DEFAULT]
x=value0
[test]
y=say %(x)s

値が組み立てられる。

>>> import configparser
>>> c=configparser.ConfigParser()
>>> c.read_file(open("example.ini"))
>>> c.get("test","y")
'say value0'

環境変数を使ってテンプレーティングする

ConfigParser instance 生成時の引数で DEFAULT セクションを設定することができるので、ConfigParser(os.environ) という方法が考えられる。

example.py
import os
import configparser
c=configparser.ConfigParser(os.environ)
c.read_file(open("example.ini"))

このようにすると、次のように使うことができる。

example.ini
[flask]
SQLALCHEMY_DATABASE_URI=mysql://%(MYSQL_USER)s@%(MYSQL_HOST)s/%(MYSQL_DATABASE)s

python 起動時に環境変数から値を渡す。

MYSQL_USER=test MYSQL_HOST=devdb.local MYSQL_DATABASE=example python example.py

一見便利に使えそうに思える。として見かけることもある。

落とし穴

実は落とし穴がある。

import os,configparser
c=configparser.ConfigParser(os.environ)
print(c.items("DEFAULT"))

このような python プログラムを実行すると例外が発生することがある。

$ python fail.py
Traceback (most recent call last):
  File "/home/ubuntu/fail.py", line 3, in <module>
    print(c.items("DEFAULT"))
  File "/usr/lib/python3.9/configparser.py", line 859, in items
    return [(option, value_getter(option)) for option in orig_keys]
  File "/usr/lib/python3.9/configparser.py", line 859, in <listcomp>
    return [(option, value_getter(option)) for option in orig_keys]
  File "/usr/lib/python3.9/configparser.py", line 855, in <lambda>
    value_getter = lambda option: self._interpolation.before_get(self,
  File "/usr/lib/python3.9/configparser.py", line 395, in before_get
    self._interpolate_some(parser, option, L, value, section, defaults, 1)
  File "/usr/lib/python3.9/configparser.py", line 442, in _interpolate_some
    raise InterpolationSyntaxError(
configparser.InterpolationSyntaxError: '%' must be followed by '%' or '(', found: '%s %s'

典型的には次のようにすると再現する。

$ A="% %" python fail.py

一般的な shell 環境では、環境変数の値に "%" を使うものがいくつかあるため、意図しない interpolation を行おうとして失敗している。

Workaround

解決方法はたくさん考えられるが、python 内で処理するのであれば、pydantic.BaseSettings は環境変数を自動処理するので便利。configparser interpolation を使いたい場合は、pydantic.BaseSettings で validation を通過したものだけを、改めて DEFAULT として使うという手もある。

from configparser import ConfigParser
from pydantic import BaseSettings
class AppDefaults(BaseSettings):
	MYSQL_USER: str
	MYSQL_HOST: str
	MYSQL_DATABASE: str

c = ConfigParser(AppDefaults().dict())
c.read_file(open("example.ini"))

Discussion