[Python] jashin.dictattrで辞書の要素を属性値として参照する

Pythonで辞書データをたくさん扱うコードを書いていると、辞書の要素をオブジェクトの属性みたいに参照したくなることがあります。

Javascriptなんかだと、辞書でも

data = {
    'name': 'value'
}

alert(data.name)

のように、辞書.名前 で参照できますが、Pythonの場合は

data = {
    'name': 'value'
}

print(data['name'])

となり、数が多いとちょっと面倒になります。

ありがちな実装

辞書をオブジェクト風にアクセスする手法としては、__getattr__() を使ったカスタマイズがまず思いつきます。

In [1]:
class DictWrapper:
    def __init__(self, d):
        self.dict = d
    
    def __getattr__(self, name):
        return self.dict[name]

data = {
    'name': 'value'
}

dictobj = DictWrapper(data)
print("data['name'] は", dictobj.name, "です")
data['name'] は value です

しかし、この程度の実装だと、現実のアプリケーションではあんまり役に立ちません。普通、Webサービスなどで取得するようなデータは辞書の要素として他の辞書やリストなどを含んだ、複雑な構造になっています。たとえば、GithubのAPIで リポジトリのイベントを取得 すると、次のような辞書が返ってきます。

In [2]:
import requests
requests.get('https://api.github.com/repos/sojin-project/jashin/events', params={'per_page':1}).json()
Out[2]:
[{'id': '13308237940',
  'type': 'CreateEvent',
  'actor': {'id': 1088786,
   'login': 'atsuoishimoto',
   'display_login': 'atsuoishimoto',
   'gravatar_id': '',
   'url': 'https://api.github.com/users/atsuoishimoto',
   'avatar_url': 'https://avatars.githubusercontent.com/u/1088786?'},
  'repo': {'id': 269219109,
   'name': 'sojin-project/jashin',
   'url': 'https://api.github.com/repos/sojin-project/jashin'},
  'payload': {'ref': '0.0.7-2',
   'ref_type': 'tag',
   'master_branch': 'master',
   'description': 'Assorted Python utilities',
   'pusher_type': 'user'},
  'public': True,
  'created_at': '2020-08-26T00:21:49Z',
  'org': {'id': 67294772,
   'login': 'sojin-project',
   'gravatar_id': '',
   'url': 'https://api.github.com/orgs/sojin-project',
   'avatar_url': 'https://avatars.githubusercontent.com/u/67294772?'}}]

この辞書を、先程のクラスでラップして見るとこんな感じになります。

In [3]:
data = requests.get('https://api.github.com/repos/sojin-project/jashin/events', params={'per_page':1}).json()

dictobj = DictWrapper(data[0])
print("id は", dictobj.id, "です")
print("created_at は", dictobj.created_at, "です")
print("actor は", dictobj.actor, "です")
id は 13308237940 です
created_at は 2020-08-26T00:21:49Z です
actor は {'id': 1088786, 'login': 'atsuoishimoto', 'display_login': 'atsuoishimoto', 'gravatar_id': '', 'url': 'https://api.github.com/users/atsuoishimoto', 'avatar_url': 'https://avatars.githubusercontent.com/u/1088786?'} です

dictobj.id はいい感じに参照できていますが、dictobj.actor i.itertmは別の辞書をそのまま持ってきてしまうので、あんまり便利ではありません。結局辞書を参照する羽目になるのが残念です。

また、この例のようにAPIとして定義されているような辞書は、要素の名前やデータ型がちゃんと分かるようにクラスを定義しておきたいものです。

jashin.dictattr

ということで、辞書の要素を参照するクラスを定義する jashin.dictattr というモジュールを作成しました。jashin.dictattrは、

pip3 install jashin

でインストールできます。

jashin.dictattr を使うと、次のようにクラスを定義できます・

In [4]:
from jashin.dictattr import ItemAttr, DictModel

class GithubEvent(DictModel):
    id = ItemAttr()
    created_at = ItemAttr()

event = GithubEvent(data[0])
print("id は", event.id, "です")
print("created_at は", event.created_at, "です") 
id は 13308237940 です
created_at は 2020-08-26T00:21:49Z です

ItemAttr でクラス属性を作成すると、同じ名前のアイテムを辞書から取得するようになっています。

変換関数の指定

辞書の値を参照・設定するときの変換関数も指定できるので、created_at のような日付データを参照するときには、文字列データを datetime 型に変換するように指定できます。

In [5]:
from datetime import datetime, timezone
from dateutil.parser import parse as dateparse

def load_date(s: str) -> datetime:
    return dateparse(s)

def dump_date(d: datetime) -> str:
    return d.isoformat()

class GithubEvent(DictModel):
    id = ItemAttr()
    created_at = ItemAttr(load_date, dump_date)

event = GithubEvent(data[0])
print("created_at は", event.created_at, "です")
created_at は 2020-08-26 00:21:49+00:00 です

create_atに値を設定すると、dump_date()datetime型を文字に変換した結果を辞書に格納します。

In [6]:
print("変更前:", data[0]['created_at'])

event.created_at = datetime(9999, 1, 1, tzinfo=timezone.utc)

print("変更前:", data[0]['created_at'])
変更前: 2020-08-26T00:21:49Z
変更前: 9999-01-01T00:00:00+00:00

クラス定義のネスト

上記の actor のように、辞書の中にまた別の辞書がある場合は、内部の辞書にもクラスを定義して参照できます。まず、actor を、次のように定義します。

In [14]:
class Actor(DictModel):
    login = ItemAttr()
    url = ItemAttr()

そして、class GithubEvent クラスでは、actor の変換関数としてこの Actor を指定します。

In [8]:
class GithubEvent(DictModel):
    id = ItemAttr()
    created_at = ItemAttr(load_date, dump_date)
    actor = ItemAttr(Actor)

これで、event.actor という形式で参照できるようになりました。更新も可能です。

In [9]:
event = GithubEvent(data[0])
print("id は", event.id, "です")
print("actor は", event.actor.login, "です")

event.actor.login = "XXXXXX"

print("更新後の actor は", event.actor.login, "です")
id は 13308237940 です
actor は atsuoishimoto です
更新後の actor は XXXXXX です

型アノテーション

ItemAttrジェネリック型 ですので、次のように型を指定できます。

In [15]:
class Actor(DictModel):
    login = ItemAttr[str]()
    url = ItemAttr[str]()

型を指定しない場合でも、変換関数を指定していれば、変換関数から推論して型チェックが行われます。例えば、上記の

def load_date(s: str) -> datetime:
    return dateparse(s)

def dump_date(d: datetime) -> str:
    return d.isoformat()

class GithubEvent(DictModel):
    id = ItemAttr()
    created_at = ItemAttr(load_date, dump_date)

では、load_date() の戻り値が datetime と宣言されていますので、ここから GithubEvent.created_atdatetime 型として認識されます。