Edited at

Python(標準ライブラリのみ)で国際化対応する方法


やりたかったこと

Pythonで国際化対応をやったことが無いので調べてみた。

本番環境に適用するつもりはないので、最低限の機能を標準ライブラリで実現することを目指した。

本番環境で同じことをやるならBabelを導入するのが便利そう。お勉強やちょっとしたプログラム程度なら標準モジュールの方が手軽だけど、ちょっとしたプログラムで国際化対応はしないと思う。


「国際化対応」とは何か?

「国際化対応」というものにも種類があり、これを理解していないとドキュメントを読む時に混乱する。各用語は割とフワッとした意味で使われているようで、正確な定義を見つけることはできなかった。

Pythonにおいて国際化対応を示す用語は大体次の通り。ちなみにinternationalizationをi18nと略すのは、最初のiと最後のnとの間に18文字あるからだとか。(ちょっと無理があると思う)


i18n(internationalization: 国際化)

プログラムを複数の地域で動くように設計すること。実際に開発する必要はない。

例えば:


  • 地域ごとに固有の文字(日本語における漢字)が使用できるようにunicode対応する

  • 地域ごとに適切な言語で表示できるように翻訳機能を組み込む

  • タイムゾーンが異なる場合を考慮する


l10n(localization: 地域最適化)

上記i18nの設計を元に地域に最適化したプログラムを開発すること。i18nに加えてUIの変更や機能の変更を含む場合もあるようだ。

例えば:


  • お金の単位や表示方法の慣習に対応する

  • 数字の表示方法の慣習に対応する

  • 地域ごとの日付形式(2018/01/31や31-Jan-2018等)へ対応する

  • 日本語圏では左上に表示させていた戻るボタンをアラビア語圏では右上に表示

  • 中国用にQRコードでの決済機能を導入


i18n対応に必要なパッケージの一覧

課題
パッケージ

unicode対応
(Python3なら不要)

翻訳機能
gettext

日付形式
datetime

お金の単位
locale

数字の表記
locale


gettextを使った翻訳機能の実装

gettextとは元々C言語の翻訳を目的として作られたフレームワークのこと。またこのフレームワークで翻訳するためのツール群を指す場合もある。一番有名な実装はGNU gettext。

https://www.gnu.org/software/gettext/

Pythonで同様の仕組みを利用するにはPython用の実装を使用する必要がある。Pythonではこれをgettextという標準モジュールで公開している。従って新規にツールを導入する必要はない。

【Python gettextモジュールの公式ドキュメント】

https://docs.python.org/ja/3.7/library/gettext.html

作業の流れは以下の通り。


  • プログラムの最初にimport gettextして翻訳のための設定を行う

  • プログラムが出力する文字の内「翻訳の対象となる文字列」をマークアップする

  • .pyファイルをツールに食わせて、マークアップした文字列を抽出する

  • .poファイルを適切なディレクトリに配置する

  • 抽出した文字列を翻訳する

  • 翻訳した結果をコンパイルして.moファイルにする

以下のハンズオンでは最終的に次のような構成となる。

python-i18n-sample

├app
│ ├__init__.py
│ ├app.py
│ └bootstrap.py
└locale
└ja_JP
│ └LC_MESSAGES
│ ├messages.mo
│ └messages.po
└ messages.pot

翻訳用の辞書ファイルはlocaleディレクトリ以下に置く(ディレクトリ名自体は任意)。このディレクトリは必ず以下の構成にしなくてはならない。

https://docs.python.org/ja/3.7/library/gettext.html#gettext.find

locale_dir

└language
└LC_MESSAGES
└domain.mo


import gettextして翻訳のための設定を行う

プログラムの最初に次のような記述を入れる


bootstrap.py

import gettext

import os

def init_translation():
# 翻訳ファイルを配置するディレクトリ
path_to_locale_dir = os.path.abspath(
os.path.join(
os.path.dirname(__file__),
'../locale'
)
)

# 翻訳用クラスの設定
translater = gettext.translation(
'messages', # domain: 辞書ファイルの名前
localedir=path_to_locale_dir, # 辞書ファイル配置ディレクトリ
languages=['ja_JP'], # 翻訳に使用する言語
fallback=True # .moファイルが見つからなかった時は未翻訳の文字列を出力
)

# Pythonの組み込みグローバル領域に_という関数を束縛する
translater.install()

# プログラムを実行
if __name__ = '__main__':
import app
init_translation()
app.main()



「翻訳の対象となる文字列」をマークアップする

プログラムが出力する文字列を_という名前の関数で囲む。これにより後述する処理で「翻訳対象の文字列」であると認識される。

※英語がアレなのはご容赦ください


app.py

def main():

print(_('This is sample message.'))
print('This string isn\'t translated.')


ツールに食わせてマークアップした文字列を抽出する

まずは必要なツールの場所を確認する。以下のディレクトリの中身を確認する。

[Pythonインストールディレクトリ]/Tools/i18n/


  • pygettext.py

  • msgfmt.py

上記2つがあればよい。このファイルを作業用ディレクトリにコピーするか、ディレクトリにパスを通しておく。続いて以下のコマンドを打つ。

# app.pyをツールに食わせる。

# ファイルが複数ある場合は末尾に全て列挙する
python pygettext.py -d messages -p locale app/app.py

出力されたmessages.potを開くと次のようになっているはず


messages.pot

# SOME DESCRIPTIVE TITLE.

# Copyright (C) YEAR ORGANIZATION
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"POT-Creation-Date: 2018-08-19 10:40+0900\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=cp932\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: pygettext.py 1.5\n"

#: app/app.py:2
msgid "This is sample message."
msgstr ""


Windowsだと上記の通りContent-Typeがcharset=cp932になっているはずなので、utf-8に修正する。

 ※cp932とはShift-JISのこと


.poファイルを適切なディレクトリに配置する

各言語用のディレクトリにファイルを配置する。この時の拡張子は.poであることに注意する。

cp locale/messages.pot locale/ja_JP/LC_MESSAGES/messages.po


抽出した文字列を翻訳する

messages.poをテキストエディターで編集する


messages.po

#: app/app.py:2

msgid "This is sample message."
msgstr "これはサンプルメッセージです"


翻訳した結果をコンパイルして.moファイルにする

.poファイルをmsgfmt.pyに食わせると、同じディレクトリに.moファイルが出力される。

python msgfmt.py locale/ja_JP/LC_MESSAGES/messages.po


実行してみる

こうなったらOK。

> python app/bootstrap.py

これはサンプルメッセージです
This string isn't translated.


やってみた所感

この方法は毎回.poファイルを新規に出力している点がよろしくない。プログラムは修正される。その度に全ての翻訳作業をやり直すなどありえない!

この問題はGNU gettextに含まれるmsgmergeというコマンドで解決できるのだけれど、それぐらいならPython Babelを導入してしまった方がpipが使えて楽だ。


datetimeを使ったタイムゾーンを意識した時刻の実装

タイムゾーンを意識しない場合、例えば次のようなコードを書く。

from datetime import datetime

current_time = datetime.now()

この実装はタイムゾーンの情報を渡していない。これをタイムゾーンを意識した実装にするとこうなる。

from datetime import datetime, timezone

current_time = datetime.now(timezone.utc)

# 任意の時刻を作成する場合はこう
update_time = datetime(2018, 8, 19, 12, 0, 0, tzinfo=timezone.utc)

これをローカルのタイムゾーンで取得するには、こう書く。

>>> print(current_time.astimezone())

2018-08-19 11:49:31.038673+09:00

任意のタイムゾーンで時刻を取得したい場合はこのように書く。

from datetime import datetime, timezone, timedelta

current_time = datetime.now(timezone.utc)
timezone_newyork = timezone(timedelta(hours=-5))

>>> print(current_time.astimezone(timezone_newyork))

2018-08-18 21:57:22.684631-05:00

異なるタイムゾーン同士であっても比較可能。

>>> current_time = datetime.now(timezone.utc)

>>> tokyo = timezone(timedelta(hours=9))
>>> newyork = timezone(timedelta(hours=-5))
>>> print(current_time.astimezone(tokyo) == current_time.astimezone(newyork))
True


やってみた所感

単一のタイムゾーンを扱うだけなら、わざわざpytzを導入しなくても十分簡単に実装できた。

これが複数になると管理が面倒なのでimport pytzしてpytz.timezone('Asia/Tokyo')とかした方が楽っぽい。


loclaeを使った数字/通貨の表示

これは初めて知ったのだが、数字の表記方法も国によって違いがあるらしい。以下Wikipediaの該当記事。

https://ja.wikipedia.org/wiki/小数点

Pythonではlocaleモジュールを使うことで数字にも通貨にも対応可能。コードは以下の通り。

import locale

locale.setlocale(locale.LC_ALL, '') # 全ロケール情報をユーザー環境のデフォルトに設定する場合
locale.setlocale(locale.LC_ALL, 'ja_JP.UTF-8') # 任意のロケール情報に設定する場合1
locale.setlocale(locale.LC_ALL, ('ja_JP', 'UTF-8')) # 任意のロケール情報に設定する場合2

たったこれだけのコードで数値や通貨の表記を良きに計らってくれる。

>>> # 数字の出力方法

... print(locale.format_string('%d', 12345))
12345
>>> print(locale.format_string('%d', 12345, grouping=True))
12,345
>>> print(locale.format_string('%f', 12345.6789, grouping=True))
12,345.678900
>>>
>>> # 通貨の出力方法
... print(locale.currency(12345))
¥12345
>>> print(locale.currency(12345, symbol=False))
12345
>>> print(locale.currency(12345, grouping=True))
¥12,345
>>> print(locale.currency(12345, grouping=True, international=True))
JPY12,345


やってみた所感

ちなみに私の環境(Windows)でlocale.setlocale(locale.LC_ALL, '')するとJapanese_Japan.932というアヤシゲな値が設定されたので、"ja_JP"等の値を指定してやる方が心穏やかでいられそうに感じた。PythonとWindowsとの相性はそれほど良くないのかもしれない。

 ※ドキュメントによるとlocale文字列はRFC1766に従うとある(2018年8月現在、RFC1766は廃止され3066に引き継がれている)