環境変数で設定を管理したくなった場合のpython-dotenvのメモ

最近は設定ファイルだけではなく環境変数で設定を渡すことがけっこうある(コンテナ的な文脈だったり、12 factor app的な文脈だったり色々)。自前で頑張っても良いけれど。自分でコードを書かずに済ませたい。

何か良いライブラリは無いかなと探してみたらあったので使いかたのメモ。

github.com

python-dotenv

Get and set values in your .env file in local and production servers. tada

install

$ pip install python-dotenv

挙動

getting startedを読むだけでは挙動を把握できなかったので色々調べてみた。

挙動を一言で言うと、os.environに対してdefault値を与えるもの。

たとえば以下のような.envがあるとする。

.env

prefix=hello
message=${prefix} world

このとき環境変数を指定して実行せずともdefault値として.envに記述された値が使われる。 (正確に言うなら環境変数として指定された名前の値を保持していない場合に.envから読み込む)

main.py

import os
from dotenv import load_dotenv

load_dotenv(verbose=True)
print("prefix", os.environ.get("prefix"))
print("message", os.environ.get("message"))

ここで

defaultは.envに記述された値

$ python main.py
prefix hello
message hello world

環境変数を与えるとその値が使われる

$ prefix=bye python main.py
prefix bye
message bye world

case sensitive(大文字小文字を区別する)

$ PREFIX=bye python main.py
prefix hello
message hello world

なのでlocal用のaccess tokenを.envに記述し本番では環境変数越しに値を渡すということが想定された使われ方(12 factor app的な文脈)。

override=True

override=Trueを使うとdotenv側の設定を尊重する。正直使いみちはあまりないと思う。

$ python main2.py
prefix hello
message hello world

$ prefix=bye python main2.py
prefix hello
message hello world

環境変数で指定した値が使われなくなる。

変更のdiff

--- main.py  2019-04-29 15:48:35.499386628 +0900
+++ main2.py  2019-04-29 16:29:58.014889108 +0900
@@ -1,6 +1,6 @@
 import os
 from dotenv import load_dotenv
 
-load_dotenv(verbose=True)
+load_dotenv(verbose=True, override=True)
 print("prefix", os.environ.get("prefix"))
 print("message", os.environ.get("message"))

.envで設定した部分だけを取り出す

これは dotenv_values() で取り出せる。configとして扱う分にはこちらのほうが便利かもしれない。

from dotenv import load_dotenv, dotenv_values

load_dotenv(verbose=True)
print(dotenv_values(verbose=True))

実行結果

{'prefix': 'hello', 'message': 'hello world'}

このときの.env

prefix=hello
message=${prefix} world

追記

:warning: 環境変数込みで渡した場合にも.env側の値が取り出されるので、configとして使うには危険かも(実装を考えるとこの挙動はあり得る)

$ python main.py
{'prefix': 'hello', 'message': 'hello world'}

$ prefix=bye python main*.py
{'prefix': 'hello', 'message': 'bye world'}

prefixは.envに書かれたものが使われる。一方messageは環境変数の解釈が入りhelloではなくbyeが使われる。

ちょっとひと手間だけれど、以下の様にすれば期待したふるまいになりそう。

import os

print({k: os.environ.get(k) for k in dotenv_values()})
# {'prefix': 'bye', 'message': 'bye world'}

複数の.envが用意されていた場合に読まれる.envについて

これはfind_dotenv()の実行で確認できる。複数のファイルを全部マージするなどと言ったマジカルな挙動は存在しない。最初に見つかった.envファイルを使う。それでおしまい。

ただし現在の位置に存在しなかった場合に親方向を辿って探してはくれる。

$ tree -a
.
|-- .env
|-- main.py
|-- sub
|   |-- .env
|   `-- main.py
`-- sub2
    `-- main.py

2 directories, 5 files

ここでそれぞれ見られる.envは以下の様な形。

  • main.py は.env
  • sub/main.py は sub/.env
  • sub2/main.py は .env

調査用のコードは以下のようなもの。

main.py

from dotenv import find_dotenv, dotenv_values

print(find_dotenv())
print(dotenv_values())

実行結果

$ python main.py
~/my/example_dotenv/03find/.env
{'x': 'root'}
$ python sub/main.py
~/my/example_dotenv/03find/sub/.env
{'x': 'sub', 'y': 'value'}

$ python sub2/main.py
~/my/example_dotenv/03find/.env
{'x': 'root'}

このときの.env

.env

x="root"

sub/.env

x=sub
y=value

subprocessの実行時の環境変数

subprocessに環境変数を引き継ぐ方法は通常のsubprocessの利用と変わらない。dotenvで読み込んだときに環境変数に設定するので。むしろこれのために環境変数ベースで設定をしていると言っても良い(processとsubprocessとの間でのdynamic scope的な辞書)。

たとえば先程の.env(再掲)を以下の様なsubprocessを利用したコードで使う。

.env

prefix=hello
message=${prefix} world

subprocessを利用したコード

main2.py

import subprocess
from dotenv import load_dotenv

load_dotenv(verbose=True)


cmd = ["python", "-c", "import os; print(os.environ.get('message', '<none>'))"]
subprocess.run(cmd, check=True)

messageという環境変数が引き継がれる。

$ python main2.py
hello world

個別に設定したい場合

これはオフトピックだけれど、個別に設定したい場合はsubprocess.runenvオプションに辞書を渡せば良い。

main3.py

import os
import subprocess
from dotenv import load_dotenv

load_dotenv(verbose=True)


cmd = ["python", "-c", "import os; print(os.environ.get('message', '<none>'))"]

myenv = os.environ.copy()
myenv["message"] = "bye, bye"

subprocess.run(cmd, check=True)
subprocess.run(cmd, check=True, env=myenv)
subprocess.run(cmd, check=True)

結果

$ python main3.py
hello world
bye, bye
hello world

jsonschemaやopenAPI docのファイルを分解する機能を作った

github.com

背景

jsonschemaやopenAPI documentを触っている時に、時折、例としてあげられるファイルが巨大でインデントなどの対応を把握するのが辛くなることがある。

自分で書く場合には複数のファイルに分解された状態で書くので問題ないのだけれど。

手動で分解するのも面倒だったので。分解する機能をjsonknifeに追加した。

機能

separateという名前にした。適切な名前な気がしないのでもう少し良い名前を付けたい。 separateは過去に作ったbundleの機能と対になっている。

以下の様な対応関係。

  • bundle -- 複数のファイルをまとめて1つのファイルにする
  • separate -- 1つのファイルを分解して複数のファイルにする

例えば以下のようなJSONSchemaの定義があるとする。"#/definitions/address" の部分だけでも別ファイルになれば見やすいのになーと思ったりしたとする。

schema.json

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "definitions": {
    "address": {
      "properties": {
        "city": {
          "$ref": "#/definitions/name"
        },
        "state": {
          "type": "string"
        },
        "street_address": {
          "type": "string"
        }
      },
      "required": [
        "street_address",
        "city",
        "state"
      ],
      "type": "object"
    },
    "name": {
      "type": "string"
    }
  },
  "properties": {
    "billing_address": {
      "$ref": "#/definitions/address"
    },
    "shipping_address": {
      "$ref": "#/definitions/address"
    }
  },
  "type": "object"
}

このときにseparateを使って1つのファイル(schema.json)を複数のファイルに分解する。

$ jsonknife separate --src schema.json --format json --dst main.json
INFO:jsonknife.separator:emit file definitions/address.json
INFO:jsonknife.separator:emit file definitions/name.json
INFO:jsonknife.separator:emit file main.json

3つのファイルに分解された。

.
├── main.json
├── definitions
│   ├── address.json
│   └── name.json
└── schema.json  # これは元のファイル

分解された個々のファイルは以下のような内容になっている。

main.json

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "definitions": {
    "address": {
      "$ref": "definitions/address.json#/definitions/address"
    }
  },
  "properties": {
    "billing_address": {
      "$ref": "#/definitions/address"
    },
    "shipping_address": {
      "$ref": "#/definitions/address"
    }
  },
  "type": "object"
}

main.jsonがentrypoint的なもの。参照先は別のファイルになっているので全体の構造が把握しやすい。

(トップレベルのdefinitionsを削除してしまっても良い気もしたが残している。)

次に参照先のaddressの定義。ファイルの位置関係は元のファイルの改装構造とディレクトリの位置を対応させている。

definitions/address.json

{
  "definitions": {
    "address": {
      "properties": {
        "city": {
          "$ref": "name.json#/definitions/name"
        },
        "state": {
          "type": "string"
        },
        "street_address": {
          "type": "string"
        }
      },
      "required": [
        "street_address",
        "city",
        "state"
      ],
      "type": "object"
    }
  }
}

addressからしかnameは参照されないのでmain.jsonのdefinitionsにはnameが現れていない。

そのnameは以下のようなもの。

definitions/name.json

{
  "definitions": {
    "name": {
      "type": "string"
    }
  }
}

再帰的定義

再規定機な定義にも対応している。

例えば以下のようなファイル。

schema2.json

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "definitions": {
    "person": {
      "properties": {
        "children": {
          "default": [],
          "items": {
            "$ref": "#/definitions/person"
          },
          "type": "array"
        },
        "name": {
          "type": "string"
        }
      },
      "type": "object"
    }
  },
  "properties": {
    "person": {
      "$ref": "#/definitions/person"
    }
  },
  "type": "object"
}

これは以下のように(defaultはyamlで出力)

.
├── main2.yaml
├── definitions
│   └── person.yaml
└── schema2.json  # 元のファイル

main2.yaml

$schema: 'http://json-schema.org/draft-07/schema#'
definitions:
  person:
    $ref: 'definitions/person.yaml#/definitions/person'
properties:
  person:
    $ref: '#/definitions/person'
type: object

definitions/person.yaml

definitions:
  person:
    properties:
      children:
        default: []
        items:
          $ref: '#/definitions/person'
        type: array
      name:
        type: string
    type: object

複雑なもの

複雑なファイルに実行するとけっこうすごい感じになる。 たとえば speccyが持っているopenAPI3.0のjsonschemaを渡して分解したときに作られるファイルは以下の様なもの。

すごくたくさんのファイルが作られる。

.
├── main.json
├── definitions
│   ├── APIKeySecurityScheme.json
│   ├── AuthorizationCodeOAuthFlow.json
│   ├── BearerHTTPSecurityScheme.json
│   ├── Callback.json
│   ├── ClientCredentialsFlow.json
│   ├── Components.json
│   ├── Contact.json
│   ├── Discriminator.json
│   ├── Encoding.json
│   ├── Example.json
│   ├── ExternalDocumentation.json
│   ├── HTTPSecurityScheme.json
│   ├── Header.json
│   ├── HeaderWithContent.json
│   ├── HeaderWithSchema.json
│   ├── HeaderWithSchemaWithExample.json
│   ├── HeaderWithSchemaWithExamples.json
│   ├── ImplicitOAuthFlow.json
│   ├── Info.json
│   ├── License.json
│   ├── Link.json
│   ├── LinkWithOperationId.json
│   ├── LinkWithOperationRef.json
│   ├── MediaType.json
│   ├── MediaTypeWithExample.json
│   ├── MediaTypeWithExamples.json
│   ├── NonBearerHTTPSecurityScheme.json
│   ├── OAuth2SecurityScheme.json
│   ├── OAuthFlows.json
│   ├── OpenIdConnectSecurityScheme.json
│   ├── Operation.json
│   ├── Parameter.json
│   ├── ParameterWithContent.json
│   ├── ParameterWithContentInPath.json
│   ├── ParameterWithContentNotInPath.json
│   ├── ParameterWithSchema.json
│   ├── ParameterWithSchemaWithExample.json
│   ├── ParameterWithSchemaWithExampleInCookie.json
│   ├── ParameterWithSchemaWithExampleInHeader.json
│   ├── ParameterWithSchemaWithExampleInPath.json
│   ├── ParameterWithSchemaWithExampleInQuery.json
│   ├── ParameterWithSchemaWithExamples.json
│   ├── ParameterWithSchemaWithExamplesInCookie.json
│   ├── ParameterWithSchemaWithExamplesInHeader.json
│   ├── ParameterWithSchemaWithExamplesInPath.json
│   ├── ParameterWithSchemaWithExamplesInQuery.json
│   ├── PasswordOAuthFlow.json
│   ├── PathItem.json
│   ├── Paths.json
│   ├── Reference.json
│   ├── RequestBody.json
│   ├── Response.json
│   ├── Responses.json
│   ├── Schema.json
│   ├── SecurityRequirement.json
│   ├── SecurityScheme.json
│   ├── Server.json
│   ├── ServerVariable.json
│   ├── Tag.json
│   └── XML.json
└── schema.json  # 元のファイル

元のファイル(schema.json)と変換後のファイル(main.json)行数の差異。

$ wc schema.json main.json
 2346  5165 80453 schema.json
   71   145  1761 main.json
 2417  5310 82214 total

71行くらいなら全体を把握できる。

main.json

{
  "type": "object",
  "required": [
    "openapi",
    "info",
    "paths"
  ],
  "properties": {
    "openapi": {
      "type": "string",
      "pattern": "^3\\.0\\.\\d(-.+)?$"
    },
    "info": {
      "$ref": "#/definitions/Info"
    },
    "externalDocs": {
      "$ref": "#/definitions/ExternalDocumentation"
    },
    "servers": {
      "type": "array",
      "items": {
        "$ref": "#/definitions/Server"
      }
    },
    "security": {
      "type": "array",
      "items": {
        "$ref": "#/definitions/SecurityRequirement"
      }
    },
    "tags": {
      "type": "array",
      "items": {
        "$ref": "#/definitions/Tag"
      }
    },
    "paths": {
      "$ref": "#/definitions/Paths"
    },
    "components": {
      "$ref": "#/definitions/Components"
    }
  },
  "patternProperties": {
    "^x-": {}
  },
  "additionalProperties": false,
  "definitions": {
    "Info": {
      "$ref": "definitions/Info.json#/definitions/Info"
    },
    "Server": {
      "$ref": "definitions/Server.json#/definitions/Server"
    },
    "Components": {
      "$ref": "definitions/Components.json#/definitions/Components"
    },
    "Paths": {
      "$ref": "definitions/Paths.json#/definitions/Paths"
    },
    "SecurityRequirement": {
      "$ref": "definitions/SecurityRequirement.json#/definitions/SecurityRequirement"
    },
    "Tag": {
      "$ref": "definitions/Tag.json#/definitions/Tag"
    },
    "ExternalDocumentation": {
      "$ref": "definitions/ExternalDocumentation.json#/definitions/ExternalDocumentation"
    }
  },
  "description": "This is the root document object for the API specification. It combines what previously was the Resource Listing and API Declaration (version 1.2 and earlier) together into one document."
}

テストは?

テストは実際に実行した結果のdiffを見てる。簡易的な回帰テスト。 (CIでもexamplesを全部実行した後にgit diffでdiffがなければOKというもの)

一応、元のファイルとseparate後bundleしたファイルが同じ内容なことを確認している。

https://github.com/podhmo/dictknife/blob/master/examples/jsonknife/separate/Makefile