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

チルダ（`tilde`, `~`）を **Python3 の `urllib.parse.quote()` で URL エンコードすると、チルダ記号まで `%7E` とエンコードされてしまう**。[RFC-3986](https://tools.ietf.org/html/rfc3986.html#section-2.3) に準拠していないのかしら。

```python:Python3.6
import urllib.parse

str_raw = '-._~'
str_enc = urllib.parse.quote(str_raw)
print(str_enc)
```
```text:出力結果（%7Eに注目）
-._%7E
```
```text:期待する出力結果（~に注目）
-._~
```
- [オンラインで事象確認する](https://paiza.io/projects/bOxJFUK9mlebzfDhEixMxg) @ paiza.IO

## TL;DR（概要）

> Python 3.6 までの仕様です。 Python 3.7 で修正されています。

**Python 3.6 までは RFC-2396 に準拠**しており <font color=darkorange>RFC-3986 に準拠していません</font>。そのため、チルダもエンコードするのは Python <= 3.6 までの仕様です。Python 3.7[（issue #16285）で修正](https://bugs.python.org/issue16285)されています。

- `urllib.parse.quote()` は
    - Python <= 3.6 → RFC 2396
    - Python >= 3.7 → RFC 3986
- Python 3.6 以前のバージョンでは、<font color=darkorange>**`safe` パラメーターに「`~`」を追加して暫定回避できます**</font>
  - `str_enc = urllib.parse.quote(str_raw, safe='~')`

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

```python: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` パラメーターに「`~`」を追加して回避できます。

```python:Python3.6（safeパラメーター付き）
>>> 
>>> import urllib.parse
>>> 
>>> str_raw = '-._~'
>>> str_enc = urllib.parse.quote(str_raw, safe='~')
>>> print(str_enc)
-._~
```
- [オンラインで動作確認する](https://paiza.io/projects/bOxJFUK9mlebzfDhEixMxg) @ paiza.IO

## TS;DR（詳細）

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

URL エンコーディングとは、[URI](https://ja.wikipedia.org/wiki/Uniform_Resource_Identifier) で使うために[パーセントエンコーディング](https://ja.wikipedia.org/wiki/%E3%83%91%E3%83%BC%E3%82%BB%E3%83%B3%E3%83%88%E3%82%A8%E3%83%B3%E3%82%B3%E3%83%BC%E3%83%87%E3%82%A3%E3%83%B3%E3%82%B0)することです。

現在の URI の標準規格（[STD66](https://tools.ietf.org/html/std66)）では、**URL エンコードは RFC-3986 と呼ばれる標準で規格が定義されています**。（2018/12/19 現在）

そして、この [RFC-3986 の 2.3](https://tools.ietf.org/html/rfc3986.html#section-2.3) の項目で<strong>「`[a-zA-Z]` `[0-9]` `-` `.` `_` `~`」はエスケープしない仕様</strong>として定義されています。つまり、これらは "`Unreserved Characters`"（非予約文字扱い）というわけです。

<details><summary>RFC-3986-2.3 原文と日本語訳</summary><div>

> **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 <font color=darkorange>tilde</font>.
>
>      unreserved  = ALPHA / DIGIT / "-" / "." / "_" / "~"
>
> （[Uniform Resource Identifier (URI): Generic Syntax](https://tools.ietf.org/html/rfc3986.html#section-2.3) @ IETF より）

> **2.3. 非予約文字**
>
> URI 内に含む事が認められており、予約目的のない文字は、予約されていない (unreserved) と呼ばれる。 これは、大文字と小文字のアルファベット、数字、ハイフン、ピリオド、アンダースコア、<font color=darkorange>チルダ</font>が含まれる。
>
>      unreserved  = ALPHA / DIGIT / "-" / "." / "_" / "~"
> 
> （[RFC3986 日本語訳（橋本英彦 氏）の複製](https://triple-underscore.github.io/rfc-others/RFC3986-ja.html#section-2.3) |  @ GitHub より）

</div></details>

### マ？チルダ注意！

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

Python3 の `urllib.parse.quote()` は RFC-3986 に準拠しているっぽいことが[ネットで散見される](https://www.google.com/search?q=python+RFC3986+url%E3%82%A8%E3%83%B3%E3%82%B3%E3%83%BC%E3%83%89+urllib.parse.quote)ものの、PHP7 の [`rawurlencode()` 関数](http://php.net/manual/ja/function.rawurlencode.php) など**他の言語のエンコードと比較した時に結果が異なる**ことに気づきました。

```python:Python3.4.2（チルダが%7Eにエスケープされている）
$ python3
>>> import urllib.parse
>>> print(urllib.parse.quote('Matilda~Amuro'))
Matilda%7EAmuro
```
```php:PHP7.1.12
$ php -a
php > print_r(rawurlencode('Matilda~Amuro'));
Matilda~Amuro
```
```javascript:Node.js11.3.0:
$ node
> encodeURIComponent('Matilda~Amuro')
'Matilda~Amuro'
```

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

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

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

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

```python: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.<font color=darkorange>quote</font> (string, safe='/', encoding=None, errors=None) ([原文](https://docs.python.org/3.6/library/urllib.parse.html#url-quoting))**
> string 内の特殊文字を %xx を使用してエスケープします。文字、数字、および **<font color=darkorange>'_.-'</font> はクオートされません**。デフォルトでは、この関数は URL のパス部分のクオートのために用意されています。任意のパラメータ safe を指定すると、指定した ASCII 文字もクオートされません。デフォルトは '/' です。
> （ [21.8.4. URL Quoting](https://docs.python.jp/3.6/library/urllib.parse.html#url-quoting) | 21.8. urllib.parse @ Python3 日本語公式ドキュメントより）

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

> **urllib.parse.<font color=darkorange>quote</font> (string, safe='/', encoding=None, errors=None)**
> Replace special characters in string using the %xx escape. Letters, digits, and **the characters <font color=darkorange>'_.-~'</font> 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](https://tools.ietf.org/html/rfc2396.html) to [RFC 3986](https://tools.ietf.org/html/rfc3986.html) for quoting URL strings. **“~” is now included in the set of reserved characters**.
> 
> （[URL Quoting](https://docs.python.org/3.7/library/urllib.parse.html#url-quoting) | urllib.parse — Parse URLs into components」@ Python3.7 公式ドキュメントより）

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

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

### マ？チルダがアンジャン Issue に

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

```python: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 は上がっていた](https://bugs.python.org/issue16285)のですが、４年半の月日を経て「このご時世に RFC 3986 に準拠していないのはいかがなもんか」と思ったのか、2017/03/24 にやっと修正されたようです。

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

- 「[Update urllib quoting to RFC 3986](https://bugs.python.org/issue16285)」| Issue Tracker @ Python.org