Edited at

Python3 で URL エンコードするとチルダもエンコードされてしまう("~" → "%7E")

チルダ(tilde, ~)を Python3 の urllib.parse.quote() で URL エンコードすると、チルダ記号まで %7E とエンコードされてしまうRFC-3986 に準拠していないのかしら。


Python3.6

import urllib.parse

str_raw = '-._~'
str_enc = urllib.parse.quote(str_raw)
print(str_enc)



出力結果(%7Eに注目)

-._%7E



期待する出力結果(~に注目)

-._~



TL;DR(概要)


Python 3.6 は RFC-2986 に準拠しており RFC-3986 に準拠していません。そのため、チルダもエンコードするのは Python <= 3.6 までの仕様です。

Python 3.7(issue #16285)で修正されています。




  • urllib.parse.quote()


    • Python <= 3.6 → RFC 2396

    • Python >= 3.7 → RFC 3986



  • Python 3.6 以前のバージョンでは、safe パラメーターに「~」を追加して暫定回避できます。


    • str_enc = urllib.parse.quote(str_raw, safe='~')



以下は Python3.7 の Docker イメージの、コンテナ上で実行したところです。同じソースでも、ちゃんとチルダがアンエスケープ(~ のまま表示)されています。


Python3.7.1(最終行の~に注目)

$ # Python 3.7 の Docker イメージをダウンロード

$ sudo docker pull python:3.7
...(略)...
$ # コンテナを作成 & Bash で操作
$ docker run -it python:3.7 bash
root@xxxxx:/#
root@xxxxx:/# python -V
Python 3.7.1
root@xxxxx:/#
root@xxxxx:/# python
Python 3.7.1 (default, Nov 16 2018, 21:59:43)
[GCC 6.3.0 20170516] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>>
>>> import urllib.parse
>>>
>>> str_raw = '-._~'
>>> str_enc = urllib.parse.quote(str_raw)
>>> print(str_enc)
-._~

Python 3.7 より前のバージョンでは、safe パラメーターに「~」を追加して回避できます。


Python3.6(safeパラメーター付き)

>>> 

>>> import urllib.parse
>>>
>>> str_raw = '-._~'
>>> str_enc = urllib.parse.quote(str_raw, safe='~')
>>> print(str_enc)
-._~


TS;DR(詳細)


URL エンコードの正しい仕様

URL エンコーディングとは、URI で使うためにパーセントエンコーディングすることです。そして、この URL エンコードの標準規格「URI 標準規格(STD66)」では、現在の標準は RFC-3986 と呼ばれる規格で定義されています。(2018/12/19 現在)

そして、この RFC-3986 の 2.3 の項目で[a-zA-Z] [0-9] - . _ ~」はエスケープしない仕様として定義されています。つまり、これらは "Unreserved Characters"(非予約文字扱い)というわけです。

RFC-3986-2.3 原文と日本語訳


2.3. Unreserved Characters

Characters that are allowed in a URI but do not have a reserved purpose are called unreserved. These include uppercase and lowercase letters, decimal digits, hyphen, period, underscore, and tilde.

 unreserved  = ALPHA / DIGIT / "-" / "." / "_" / "~"

Uniform Resource Identifier (URI): Generic Syntax @ IETF より)

2.3. 非予約文字

URI 内に含む事が認められており、予約目的のない文字は、予約されていない (unreserved) と呼ばれる。 これは、大文字と小文字のアルファベット、数字、ハイフン、ピリオド、アンダースコア、チルダが含まれる。

 unreserved  = ALPHA / DIGIT / "-" / "." / "_" / "~"

RFC3986 日本語訳(橋本英彦 氏)の複製 | @ GitHub より)




マ?チルダ注意!

問題は、これら非予約文字のうちの「チルダ」(~)です。

Python3 の urllib.parse.quote() は RFC-3986 に準拠しているっぽいことがネットで散見されるものの、PHP7 の rawurlencode() 関数 など他の言語のエンコードと比較した時に結果が異なることに気づきました。


Python3.4.2(チルダが%7Eにエスケープされている)

$ python3

>>> import urllib.parse
>>> print(urllib.parse.quote('Matilda~Amuro'))
Matilda%7EAmuro


PHP7.1.12

$ php -a

php > print_r(rawurlencode('Matilda~Amuro'));
Matilda~Amuro


Node.js11.3.0

$ node

> encodeURIComponent('Matilda~Amuro')
'Matilda~Amuro'


あなたはエスケープかもしれない

どうやらチルダが %7E にエスケープされてしまっています。どちらもデコードすれば結果は同じですし、「問題のある文字がエンコードされないならいざ知らず、エンコードしてんだし良いじゃん」と思います。

しかし、とある UnitTest の1つが「RFC-3986 に準拠してエンコードしているか」のテストであったため、パスさせるために何とかしないといけなくなりました。(正規表現でマッチさせればいいのに…( ・´ω・`)ボソッ)

ま、urllib.parse.quotesafe パラメーターにチルダを指定するだけでエスケープの除外対象に追加されるので、現象自体は簡単に解決できます。


safeパラメーター付き

$ python3

>>> import urllib.parse
>>> s = 'Matilda~Amuro'
>>> print(urllib.parse.quote(s, safe='~'))
Matilda~Amuro

しかし、URL のエンコードはセキュリティにも関わってくるらしいので、本当に標準で RFC-3986 に準拠しているのか気になりました。


ドキュメントはお嫌?

何はともあれ、まずは公式ドキュメントの確認です。

Python 3.6 のドキュメントを見たところ、チルダさんがおりません。マジですか。つまり、RFC-3986 に準拠はしていないものの、Python の仕様通りの動きではあったわけです。


urllib.parse.quote (string, safe='/', encoding=None, errors=None) (原文)

string 内の特殊文字を %xx を使用してエスケープします。文字、数字、および '_.-' はクオートされません。デフォルトでは、この関数は URL のパス部分のクオートのために用意されています。任意のパラメータ safe を指定すると、指定した ASCII 文字もクオートされません。デフォルトは '/' です。

21.8.4. URL Quoting | 21.8. urllib.parse @ Python3 日本語公式ドキュメントより)


ところが! Python3.7.2rc1 や Python3.8-dev の英語版の最新ドキュメントを見ると、なんとチルダさんがいらっしゃるのです。


urllib.parse.quote (string, safe='/', encoding=None, errors=None)

Replace special characters in string using the %xx escape. Letters, digits, and the characters '_.-~' are never quoted. By default, this function is intended for quoting the path section of URL. The optional safe parameter specifies additional ASCII characters that should not be quoted — its default value is '/'.


しかも、チルダが追加されたことについても明記されていました


Changed in version 3.7: Moved from RFC 2396 to RFC 3986 for quoting URL strings. “~” is now included in the set of reserved characters.

URL Quoting | urllib.parse — Parse URLs into components」@ Python3.7 公式ドキュメントより)


つまり、今までは(Python3.6 までは)、URL エンコードは RFC 2396 に準拠していたが、Python3.7 から RFC 3986 に準拠するようになったということです。(RFC-2396RFC-3986

(2018/12/23 日本語のドキュメントはまだ翻訳されていないようです。RCが取れたら公開されるのかもしれません。)


マ?チルダがアンジャン Issue に

確かに Docker で Python3.7.1 のイメージでコンテナ上で検証したところ、Python3.7 では ~ はエスケープされませんでした。RFC-3986 の仕様通りの動きです。


Python3.7.1

$ sudo docker pull python:3.7

(略)
$ docker run -it python:3.7 bash
root@xxxxxxxxxxxx:/# python -V
Python 3.7.1
root@xxxxxxxxxxxx:/# python
>>> import urllib.parse
>>> s = 'Matilda~Amuro'
>>> print(urllib.parse.quote(s))
Matilda~Amuro

どういう経緯で準拠したのか興味を持ったところ、2012/10/19 には Issue は上がっていたのですが、4年半の月日を経て「このご時世に RFC 3986 に準拠していないのはいかがなもんか」と思ったのか、2017/03/24 にやっと修正されたようです。


I think ~ should be added to the safe characters of quote function