ベスパライフ

日記・備忘録。ベスパもってないです。

utf-8とiso-2022-jpのencodeとdecodeのTips

半角英数字をUTF-8でencode

>>> s = "aiueo012"
>>> b = s.encode("utf-8")
>>> b
>>> b'aiueo012'

これをdecodeする

>>> b.decode("utf-8")
'aiueo012'
>>> b.decode("iso-2022-jp")
'aiueo012'

半角英数字をutf-8エンコードしたものは、iso-2022-jpでデコードしても問題ない

半角英数字 + 日本語文字をUTF-8でencode

>>> ss = "aiueo012あいうkakikukeko"
>>> bb = ss.encode("utf-8")
>>> bb
b'aiueo012\xe3\x81\x82\xe3\x81\x84\xe3\x81\x86kakikukeko'

日本語は1文字3バイトの16進数に変換されます。
これをUTF-8iso-2022-jpでdecodeしてみます。

>>> bb.decode("utf-8")
'aiueo012あいうkakikukeko'
>>> bb.decode("iso-2022-jp")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'iso2022_jp' codec can't decode byte 0xe3 in position 8: illegal multibyte sequence

当然、UTF-8でdecodeすると元の文字列に戻ります
iso-2022-jpでdecodeしようとすると、0xe3がdecodeできなくてUnicodeDecodeErrorが発生します

>>> bb.decode("iso-2022-jp", "ignore")
'aiueo012kakikukeko'

"ignore"オプションをつけてdecodeすると、デコード不可な文字は無視して無理やりdecodeしてくれます。
今回の場合では、結果として半角英数字のみを取り出すことができました。

半角英数字 + 日本語文字をiso-2022-jpでencode

>>> bb = ss.encode("iso-2022-jp")
>>> bb
b'aiueo012\x1b$B$"$$$&\x1b(Bkakikukeko'
>>> bb.decode("iso-2022-jp")
'aiueo012あいうkakikukeko'
>>> bb.decode("utf-8")
'aiueo012\x1b$B$"$$$&\x1b(Bkakikukeko'
>>> bb.decode("utf-8", "ignore")
'aiueo012\x1b$B$"$$$&\x1b(Bkakikukeko'

iso-2022-jpでencodeしたものは、UTF-8でdecodeすると、すべて半角英数字と記号に直されるようです

python3で文字列にlower()するとidが変わるという当たり前の話

文字列比較は is ではなくて == で比較しましょう、という話です。

>>> a = "hoge"
>>> b = 'hoge'
>>> a is b
True
>>> a.lower()
'hoge'
>>> a.lower() is b
False
>>> a.lower() is a
False

…なぜ?
どうやらlower()やupper()したらidが変わってしまう様子。

>>> id(a)
1991375392
>>> id(a.lower())
1991375808
>>> id(a.lower())
1991375744        # やるたびに変わる

知りませんでした。
idはオブジェクト固有のIDなので、lower()メソッドが返す文字列が元のオブジェクトとは限らないというのは当たり前といえば当たり前の話。
そういうわけで文字列比較は == で評価しましょう。

python3でContent-Transfer-Encoding ヘッダを 変更するのに苦労した話

msg = MIMEText(
main_text.encode("utf-8", "ignore"),
'plain',
'utf-8'
)

上のようなコードでメッセージを送信したとき、Content-Transfer-Encodingヘッダは7bitでした。

Content-Type: text/plain; charset="iso-2022-jp"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Subject: =?utf-8?b?44CQTWltYW1vcmnjgJHmuKnluqYv5rm/
4K544OI44Oh44O844Or44Gn44GZ?=
To: hogehoge@test.mail
Cc:
Bcc:
Date: Mon, 19 Jun 2017 16:12:46 +0900

[この辺に本文]

上の例ではメールの本文(main_text)をbase64に変換し忘れていたことに気づき、次のようなコードに変えました

msg = MIMEText(
base64.b64encode( main_text.encode("utf-8", "ignore") ),
'plain',
'utf-8'
)

しかし、これでもEncodingヘッダは7bitでした。

Content-Type: text/plain; charset="iso-2022-jp"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Subject: =?utf-8?b?44CQTWltYW1vcmnjgJHmuKnluqYv5rm/
4K544OI44Oh44O844Or44Gn44GZ?=
To: hogehoge@test.mail
Cc:
Bcc:
Date: Mon, 19 Jun 2017 16:12:46 +0900

[この辺に本文]

Content-Transfer-Encoding ヘッダをbase64に変更するにはどうすればいいのか?どうやらemail.encoders.encode_base64()を使えばいいっぽいです

ペイロードbase64 形式でエンコードし、 Content-Transfer-Encoding ヘッダを base64 に変更します。これはペイロード中のデータのほとんどが印刷不可能な文字である場合に適しています。 quoted-printable 形式よりも結果としてはコンパクトなサイズになるからです。 base64 形式の欠点は、これが人間にはまったく読めないテキストになってしまうことです。

ということで、msgにemail.encoders.encode_base64()を使えば解決しそうです。

msg = MIMEText(
base64.b64encode( main_text.encode("utf-8", "ignore") ),
'plain',
'utf-8'
)
email.encoders.encode_base64(msg)

しかし、これをすると次のヘッダになりました

Content-Type: text/plain; charset="iso-2022-jp"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Content-Transfer-Encoding: base64
Subject: =?utf-8?b?44CQTWltYW1vcmnjgJHmuKnluqYv5rm/
4K544OI44Oh44O844Or44Gn44GZ?=
To: hogehoge@test.mail
Cc:
Bcc:
Date: Mon, 19 Jun 2017 16:12:46 +0900

[この辺に本文]

" Content-Transfer-Encoding:base64 "が追加されています。変更するんじゃないのかよ。
小一時間悩みました。しかしどうということはなさそうです。文章はちゃんと読みましょう。

ペイロードbase64 形式でエンコードし、 Content-Transfer-Encoding ヘッダを base64 に変更します。

email.encoders.encode_base64()自体が、base64変換の機能を持っていました。どうやら二重でbase64変換していたのが原因のようでした。
というわけで、次のように変更。

msg = MIMEText(
main_text.encode("utf-8", "ignore"),
'plain',
'utf-8'
)
email.encoders.encode_base64(msg)

ヘッダを確認します

Content-Type: text/plain; charset="iso-2022-jp"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Content-Transfer-Encoding: base64
Subject: =?utf-8?b?44CQTWltYW1vcmnjgJHmuKnluqYv5rm/
4K544OI44Oh44O844Or44Gn44GZ?=
To: hogehoge@test.mail
Cc:
Bcc:
Date: Mon, 19 Jun 2017 16:12:46 +0900

[この辺に本文]

は?
やっぱり、Content-Transfer-Encodingヘッダが追加されてしまいます。どうなっとんじゃ。
email.encoders.encode_base64()の元のソースコードを覗いてみます。

def encode_base64(msg):
"""Encode the message's payload in Base64.
Also, add an appropriate Content-Transfer-Encoding header.
"""
orig = msg.get_payload(decode=True)
encdata = str(_bencode(orig), 'ascii')
msg.set_payload(encdata)
msg['Content-Transfer-Encoding'] = 'base64'

ぱっと見た感じ、
(1). msgのペイロードを取り出し、ついでにデコードもする(orig)
(2). origをasciiでエンコードする(encdata)
(3). msgのペイロードにencdataをセットする
(4). msgのContent-Transfer-Encodingヘッダをbase64にする
という処理をしてそうです
どうやら、email.encoders.encode_base64()を使わなくてもmsg['Content-Transfer-Encoding'] = 'base64'とやるだけでヘッダを変更できそうです。そもそも私はヘッダ情報を変更したいだけだったんだ。これでいきましょう。

msg = MIMEText(
main_text.encode("utf-8", "ignore"),
'plain',
'utf-8'
)
msg['Content-Transfer-Encoding'] = 'base64'
msg['Content-Transfer-Encoding'] = 'base64'
msg['Content-Transfer-Encoding'] = 'base64'

ヘッダを確認します

Content-Type: text/plain; charset="iso-2022-jp"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Content-Transfer-Encoding: base64
Content-Transfer-Encoding: base64
Content-Transfer-Encoding: base64
Subject: =?utf-8?b?44CQTWltYW1vcmnjgJHmuKnluqYv5rm/
4K544OI44Oh44O844Or44Gn44GZ?=
To: hogehoge@test.mail
Cc:
Bcc:
Date: Mon, 19 Jun 2017 16:12:46 +0900

[この辺に本文]

ここにきて気づいたのですが、どうやらmsg["hoge"] = "piyo"は、変更でなくて追加のようです。その証拠に、

msg = MIMEText(
main_text.encode("utf-8", "ignore"),
'plain',
'utf-8'
)
msg['Content-Transfer-Encoding'] = 'base64'
msg['Subject'] = 'hogehoge'
msg['Content-Transfer-Encoding'] = 'base64'
msg['Subject'] = 'hogehoge'
msg['Content-Transfer-Encoding'] = 'base64'
msg['Subject'] = 'hogehoge'
Content-Type: text/plain; charset="iso-2022-jp"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Content-Transfer-Encoding: base64
Subject: =?utf-8?b?44CQTWltYW1vcmnjgJHmuKnluqYv5rm/
4K544OI44Oh44O844Or44Gn44GZ?=
Content-Transfer-Encoding: base64
Subject: =?utf-8?b?44CQTWltYW1vcmnjgJHmuKnluqYv5rm/
4K544OI44Oh44O844Or44Gn44GZ?=
Content-Transfer-Encoding: base64
Subject: =?utf-8?b?44CQTWltYW1vcmnjgJHmuKnluqYv5rm/
4K544OI44Oh44O844Or44Gn44GZ?=
To: hogehoge@test.mail
Cc:
Bcc:
Date: Mon, 19 Jun 2017 16:12:46 +0900

[この辺に本文]

となります。
で、どうすればいいのか。
また小一時間悩んだ結果、原因はMIMETextクラスにありました。

class email.mime.text.MIMEText(_text, _subtype='plain', _charset=None, *, policy=compat32)
(中略)
_charset 引数に明示的に None をセットしない限りは、作成される MIMEText オブジェクトは charset の付いた Content-Type ヘッダと Content-Transfer-Endcoding ヘッダの両方を持ちます。これは後続の set_payload 呼び出しが、 set_payload コマンドに charset が渡したとしてもエンコードされたペイロードにはならないことを意味します。 Content-Transfer-Encoding ヘッダを削除することでこの振る舞いを「リセット」出来ます。これにより set_payload 呼び出しが新たなペイロードを自動的にエンコード (そして新たな Content-Transfer-Encoding ヘッダを追加) します。

つまり、Content-Transfer-Encodingヘッダを変更するには、
・_charset=Noneでメッセージを作成する
または
・一度Content-Transfer-Encoding ヘッダを削除してset_payload()する
の2通りのやり方があるようです。
後者の方法にContent-Transfer-Encoding ヘッダを削除と書いてありますが、削除の方法は書いてありません。「そもそもset_payloadってなんだっけ」と検索をかけると、それっぽいのがMessageクラスに見つかりました。

class email.message.Message(policy=compat32)
(中略)
replace_header(_name, _value)

Replace a header. Replace the first header found in the message that matches _name, retaining header order and field name case. If no matching header was found, a KeyError is raised.

「Messageオブジェクトの中にある最初に見つけた_nameヘッダの値を_valueに置き換える」というメンバメソッドです。
これを使えばいいんじゃないでしょうか。

msg = MIMEText(
base64.b64encode( main_text.encode("utf-8", "ignore") ),
'plain',
'utf-8'
)
msg.replace_header('Content-Transfer-Encoding', "base64")
Content-Type: text/plain; charset="iso-2022-jp"
MIME-Version: 1.0
Content-Transfer-Encoding: base64
Subject: =?utf-8?b?44CQTWltYW1vcmnjgJHmuKnluqYv5rm/
4K544OI44Oh44O844Or44Gn44GZ?=
To: hogehoge@test.mail
Cc:
Bcc:
Date: Mon, 19 Jun 2017 16:12:46 +0900

[この辺に本文]

できました!はー長かった。

ちなみに、MIMETextだけがこんな仕様なのかと疑問に思い、MIMEBaseでも試してみましたが、同じ挙動でした。たぶん他のMIMEクラスでも同様だと思います。

csv_msg = MIMEBase("text", "csv")
s = "hoge,piyo,fuga"
csv_msg.set_payload(s.encode("utf-8", "ignore"))
print(csv_msg)
"""
Content-Type: text/csv
MIME-Version: 1.0

hoge,piyo,fuga
"""
csv_msg["Content-Transfer-Encoding"] = "7bit"
encoders.encode_base64(csv_msg)
print(csv_msg)
"""
Content-Type: text/csv
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Content-Transfer-Encoding: base64

aG9nZSxwaXlvLGZ1Z2E=
"""
csv_msg["Content-Transfer-Encoding"] = "base64"
csv_msg["Content-Transfer-Encoding"] = "base64"
csv_msg["Content-Transfer-Encoding"] = "base64"
csv_msg["X-Moe"] = "honoka, kotori, umi"
print(csv_msg)
"""
Content-Type: text/csv
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Content-Transfer-Encoding: base64
Content-Transfer-Encoding: base64
Content-Transfer-Encoding: base64
Content-Transfer-Encoding: base64
X-Moe: honoka, kotori, umi

aG9nZSxwaXlvLGZ1Z2E=
"""

ところで、メールのcharsetはutf-8iso-2022-jpのどっちにしたほうがいいんですかね。

python3では文字列中の部分文字列検索や辞書にキーが存在するかはin演算子を使おう

公式がそう言っているので、そうしましょう。
https://docs.python.jp/3/library/stdtypes.html

文字列中の部分文字列検索

注釈
find() メソッドは、 sub の位置を知りたいときにのみ使うべきです。 sub が部分文字列であるかどうかのみを調べるには、 in 演算子を使ってください:

>>>
>>> 'Py' in 'Python'
True

辞書にキーが存在するかを調べる

>>>
>>> dict = {}
>>> dict["A"] = 1
>>> dict["B"] = 2
>>> dict["C"] = 3
>>> "A" in dict
True
>>> "D" in dict
False

Pythonでclassの中にclassを作成する

class TemperatureHumidity:
  class Temperature:
    def __init__(self):
      self.value = None
      self.count = 0
    def print(self):
      print("Temperature:", self.value, self.count)
  class Humidity:
    def __init__(self):
    self.value = None
    self.count = 0
    def print(self):
      print("Humidity:", self.value, self.count)

  def __init__(self):
    self.t = self.Temperature()
    self.h = self.Humidity()
  def print(self):
    self.t.print()
    self.h.print()
  def increment_count(self, _obj):
    _obj.count += 1

if __name__ == "__main__":
  t_h = TemperatureHumidity()
  t_h.t.print()
  t_h.h.print()
  t_h.increment_count(t_h.t)
  t_h.t.print()
  t_h.h.print()

継承するときはselfはいらない

以下のコードはTemperatureクラスがDataクラスを継承していますが、self.Dataと書いてはいけません。

class TemperatureHumidity:
  class Data:
    def __init__(self):
      self.value = None
      self.count = 0
  class Temperature(Data):
    def __init__(self):
      super().__init__()
    def print(self):
      print("Temperature:", self.value, self.count)
  class Humidity(Data):
    def __init__(self):
      super().__init__()
    def print(self):
      print("Humidity:", self.value, self.count)

  def __init__(self):
    self.t = self.Temperature()
    self.h = self.Humidity()
  def print(self):
    self.t.print()
    self.h.print()
  def increment_count(self, _obj):
    _obj.count += 1

if __name__ == "__main__":
  t_h = TemperatureHumidity()
  t_h.t.print()
  t_h.h.print()
  t_h.increment_count(t_h.t)
  t_h.t.print()
  t_h.h.print()

pythonでfloat()やint()で変換可能かどうかを調べる関数

標準では提供されてなかったようなので、自作helper関数として作ります。
与えられた文字列が小数 or 整数かを正規表現で調べる関数を作ったのですが、作った後でテストしているときに、そんなことしなくても例外をキャッチすればいいだけだと気づきました。

###############################################################################
# str文字列がfloat()変換できるかどうかを判定する
###############################################################################
def is_float(s):
  try:
    float(s)
  except:
    return False
  return True

同様に、int(), long(),complex()変換可能かどうかも簡単に作れます。こうして見ると例外キャッチってすごい便利ですね。

Pythonに完全なプライベート関数がないのは、きっとユニットテストのしやすさを優先させたから

だと思います。

Pythonで完全なプライベート関数を作るとき、関数名の前に__(アンダースコア2つ)をつけます。しかし、このような使い方をしているコードは余り見たことありません。
Pythonの標準ライブラリのソースコードを眺めてみてもdef _func()ばかりが目につき、def __func()と書いてあるものは少ないように感じます。

現代プログラミングの手法にでテスト駆動開発というものがあります。テストの通っていないプログラムはGitに上げるなと言われるくらいです。自作関数をunittestするとき、当然テストしたい関数をしていするのですが、例えばクラス内のプライベート関数(__hoge()みたいなやつ)をテストさせようと思うと、ちょっと工夫が必要になります(ググれば出ます)。
その工夫をすればクラス内のプライベート関数でも外部アクセスできるようになり、ユニットテストしやすくなるのですが、工夫をすればアクセスできるということはそもそも「プライベート関数じゃないじゃん」となります。
だったらもうプライベート関数かユニットテストか、みたいな話になって、結果ユニットテストを優先したのではないでしょうか、という根拠のない妄想です。

確かにプライベート関数なくてもプログラミングに慣れた者同士のチーム開発なら問題ないと思うのですが、例えば初学者を交えた開発とか、初学者に対してプライベート変数とかの概念を教えることが難しいなどの理由から、今でも初学者向け学習のプログラミング言語C言語に軍配が上がるのかなあなどと思ったりなんたり。
いや、もしかしたらデータ構造や命名規則をしっかり学習すれば、プライベート変数みたいなスコープの概念は必要ないというアンチテーゼなのかもしれない。

まあ、ろくに調べはわけではないので、もしかしたらちゃんとした理由があるのかもしれません。適当に思ったことを書きましたとさ。