ベスパリブ

プログラミングを主とした日記・備忘録です。ベスパ持ってないです。

python3.4.2のSSLContextのデフォルト引数はなんなのか

SSLContextのデフォルト引数

公式のリファレンスでは、3系ではpython3.5.3以降の情報しか掲載されていません。
ということで実際のソースを見ることにします。

私の環境では以下のパスにありました。
/usr/lib/python3.4/ssl.py

class SSLContext(_SSLContext):
    """An SSLContext holds various SSL-related configuration options and
    data, such as certificates and possibly a private key."""

_SSLContextとは何か。

import _ssl             # if we can't import it, let the error propagate
(中略)
from _ssl import _SSLContext

_sslモジュールがなんなのか正確には不明ですが、おそらくdevelop環境か昔のモジュールのどちらかでしょう。将来修正する予定のモジュールを読み込んでいると思われます。

_sslはどこにあるのか。こういう場合は、コンソールを起動して読み込んで見たらパスがわかります。

>>> import _ssl
>>> _ssl
<module '_ssl' from '/usr/lib/python3.4/lib-dynload/_ssl.cpython-34m-arm-linux-gnueabihf.so'>

.so拡張子はLinuxで共有ライブラリ(Shared Object)を指すそうです。初めて知りました。
とりあえず_ssl.cpython-34m-arm-linux-gnueabihf.soを開いてみましたが、コンパイルされていて読めませんでした(cpythonて書いてあるし)。コメントっぽいのは読めますが、残念ながらよくわかりません。行き詰まりました。

ここで気づいたのですが、普通に呼び出してみたらどうなるのでしょうか。

>> ssl.SSLContext()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: __new__() missing 1 required positional argument: 'protocol'

あっ。デフォルト引数ないんですね。そうですか。protocol引数に必ず値を指定しなくてはいけないようです。

create_default_context()を使用したとき

そもそもなぜSSLContextのデフォルト引数を調べようかと思ったかというと、create_default_context()を使用して呼び出していたからです。なので本来ソースを見るべきはこちらの関数だったというオチです。

def create_default_context(purpose=Purpose.SERVER_AUTH, *, cafile=None,
                           capath=None, cadata=None):
    """Create a SSLContext object with default settings.

    NOTE: The protocol and settings may change anytime without prior
          deprecation. The values represent a fair balance between maximum
          compatibility and security.
    """
    if not isinstance(purpose, _ASN1Object):
        raise TypeError(purpose)

    context = SSLContext(PROTOCOL_SSLv23)

    # SSLv2 considered harmful.
    context.options |= OP_NO_SSLv2

    # SSLv3 has problematic security and is only required for really old
    # clients such as IE6 on Windows XP
    context.options |= OP_NO_SSLv3

    # disable compression to prevent CRIME attacks (OpenSSL 1.0+)
    context.options |= getattr(_ssl, "OP_NO_COMPRESSION", 0)

PROTOCOL_SSLv23をセットしています。それにOP_NO_SSLv2やOP_NO_SSLv3のフラグを立てて、脆弱性のあるSSLv2等でネゴシエーションしないようにしています。
ではPROTOCOL_SSLv23はなんなのか。

ssl.PROTOCOL_SSLv23(原文) data:PROTOCOL_TLSエイリアスです。 バージョン 3.6 で撤廃: 代わりに PROTOCOL_TLS を使用してください。

ssl.PROTOCOL_TLS¶(原文) チャンネル暗号化プロトコルとして、クライアントとサーバの両方がサポートする中の、プロトコルバージョンが最も大きなものを選択します。その名前にも関わらず、このオプションは “SSL” とともに “TLSプロトコルも選択できます。 バージョン 2.7.13 で追加.

ssl.PROTOCOL_TLS(原文) チャンネル暗号化プロトコルとして、クライアントとサーバの両方がサポートする中の、プロトコルバージョンが最も大きなものを選択します。その名前にも関わらず、このオプションは “SSL” とともに “TLSプロトコルも選択できます。 バージョン 3.5.3 で追加.

ssl.PROTOCOL_TLSはバージョン3.5.3で追加されていますが、バージョン2.7.13でもすでに追加されています。バージョン2.7.13で追加されているということは、python3.4.2でも同じ挙動するはずと考えるのは甘いでしょうか。たぶん甘いでしょう。わざわざ3.5.3で追加と書いてあるのは、2系から3系に移植する際に実装を後回しにしている可能性があります。

これ以上正確な挙動を知ろうと思うなら、実際の挙動を見る他ないと思います。
たとえばgmailとSTARTTLSネゴシエーションをしたときの通信の様子は次のようになります(2017/10)

>> smtplib.SMTP("smtp.gmail.com", 587)
<smtplib.SMTP object at 0x7671df70>
>> M = smtplib.SMTP("smtp.gmail.com", 587)
>> M.set_debuglevel(True)
>> context = ssl.create_default_context()
>> M.starttls(context=context)
send: 'ehlo [127.0.1.1]\r\n'
reply: b'250-smtp.gmail.com at your service, [114.147.68.118]\r\n'
reply: b'250-SIZE 35882577\r\n'
reply: b'250-8BITMIME\r\n'
reply: b'250-STARTTLS\r\n'
reply: b'250-ENHANCEDSTATUSCODES\r\n'
reply: b'250-PIPELINING\r\n'
reply: b'250-CHUNKING\r\n'
reply: b'250 SMTPUTF8\r\n'
reply: retcode (250); Msg: b'smtp.gmail.com at your service, [114.147.68.118]\nS
IZE 35882577\n8BITMIME\nSTARTTLS\nENHANCEDSTATUSCODES\nPIPELINING\nCHUNKING\nSMT
PUTF8'
send: 'STARTTLS\r\n'
reply: b'220 2.0.0 Ready to start TLS\r\n'
reply: retcode (220); Msg: b'2.0.0 Ready to start TLS'
(220, b'2.0.0 Ready to start TLS')

園芸はじめました

実家に帰ったら親が園芸してました。良いなあと思ったので室内栽培をしてみたいと思います。

買ったもの

f:id:takeg:20170821210852j:plain

  • 植木鉢x3(1つは物入れ)
  • 二十日大根の種、時なし小かぶの種
  • 野菜の土(腐葉土)x2
  • 鉢底石
  • 鉢底ネット
  • 園芸用はさみ
  • スコップ
  • 水吹き

ちなみに全部100均で買いました。100均すごいな。

ちなみにド素人なので、ネットで集めた情報と勘で適当に買ってきました。

土づくり

土づくりも何も、買ってきたものを植木鉢にぶち込むだけです。

f:id:takeg:20170821211133j:plain

植木鉢はこんな感じです。植木鉢っていうかプランター?どっちでもいい。室内でやるので下に鉢受けを敷きます。

f:id:takeg:20170821211411j:plain

鉢底ネットを植木鉢の中に入れておくと、下から虫が入ってこないそうです。室内だから関係ないですけど。でも土や石が落ちにくくなるので多分必須です。

f:id:takeg:20170821212318j:plain

鉢底ネットを敷くとこんな感じです。

f:id:takeg:20170821211330j:plain

次は鉢底石をぶち込みます。水はけを良くして根腐れを防止するそうです。あと画像でかいな。

f:id:takeg:20170821212857j:plain

鉢底石を入れました。容器の1/5程度でいいらしいです。

f:id:takeg:20170821211350j:plain

次は腐葉土を入れます。

f:id:takeg:20170821213340j:plain

はい完成です。

種まき

f:id:takeg:20170821211310j:plain

今回は赤丸20日大根と、時なし小かぶを植えます。両方共室内栽培可能で、初心者にも育てやすいそうです。本当はシソを育てたかったんですけど売ってませんでした。

20日大根の種植え時期は春と秋、時なし小かぶは栽培時期を選ばないそうです。今は8月ですが、二十日大根は季節が悪いです。でもまあ、気にしません。

f:id:takeg:20170821222139j:plain

二十日大根は点まきにしました。画像は見にくいですが、6個の穴を空けて種を5~6粒ずつ入れました。種が重ならないように蒔くと良いそうです。知らずに蒔きました。

かぶは筋まきにすることにしました。画像はなし。

f:id:takeg:20170821224525j:plain

完成。たのしかったです(小並感)。

水は土が乾いたらやるくらい、たくさんやるといいそうなのですが、平日の日中は水やりできないので心配です。

ちなみに発芽温度が15度~28度らしいので、植木鉢に向けて扇風機を常に回しています。

f:id:takeg:20170821225816j:plain

おわり。

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