【注意】この記事は2020年11月12日現在Djangoのサポート対象になっているバージョン(2.2・3.0・3.1)を調査対象にして書きました。

DjangoではDEFAULT_CHARSETというContent-Typeのcharsetを指定する設定項目があります。デフォルト値は"utf-8"ですが、明示的にファイルに書いておこうとして以下のように書くと思わぬトラブルに繋がります。

1
DEFAULT_CHARSET = "UTF-8"  # 大文字で書いている

RFC 2616の3.4には"HTTP character sets are identified by case-insensitive tokens.“と書いているのでどちらでもよさそうな気はしますが、Djangoではutf-8の場合は必ず小文字の"utf-8"にしてください。

なぜなら、Djangoの内部ではutf-8であるかを検証するロジックでは小文字の"utf-8"で判定しているからです。大文字小文字を区別しないようにはなっていません。

具体的にトラブルになるケースを見てみましょう。サンプルアプリケーションで使用する環境は以下のとおりです。

  • Python 3.8.6
  • Django 2.2.17
  • Pillow 8.0.1

以下のようなテストコードを書いたとします。(override_settingsでわざとDEFAULT_CHARSET="UTF-8"にしています)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import io

from django.test import override_settings, TestCase
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from PIL import Image


class ImageViewTest(TestCase):
    def _generate_image_file(self, filename):
        im = Image.new(mode="RGB", size=(200, 200))
        im_io = io.BytesIO()
        im.save(im_io, "jpeg")
        im_io.seek(0)
        return SimpleUploadedFile(
            filename,
            im_io.read(),
            content_type="image/jpeg",
        )

    @override_settings(DEFAULT_CHARSET="UTF-8")
    def test_upload_image(self):
        data = {
            "content": self._generate_image_file("test.jpg"),
        }

        self.client.post("/image/create/", data)

上記テストを実行すると、ビューにリクエストを送る前の段階で以下のエラーが発生します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
======================================================================
ERROR: test_upload_image (example.tests.ImageViewTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "****/.venv/lib/python3.8/site-packages/django/test/utils.py", line 373, in inner
    return func(*args, **kwargs)
  File "****/example/tests.py", line 27, in test_upload_image
    self.client.post(reverse("example:image_create"), data)
  File "****/.venv/lib/python3.8/site-packages/django/test/client.py", line 543, in post
    response = super().post(path, data=data, content_type=content_type, secure=secure, **extra)
  File "****/.venv/lib/python3.8/site-packages/django/test/client.py", line 354, in post
    post_data = self._encode_data(data, content_type)
  File "****/.venv/lib/python3.8/site-packages/django/test/client.py", line 313, in _encode_data
    return encode_multipart(BOUNDARY, data)
  File "****/.venv/lib/python3.8/site-packages/django/test/client.py", line 201, in encode_multipart
    lines.extend(encode_file(boundary, key, value))
  File "****/.venv/lib/python3.8/site-packages/django/test/client.py", line 253, in encode_file
    to_bytes(file.read())
  File "****/.venv/lib/python3.8/site-packages/django/test/client.py", line 230, in to_bytes
    return force_bytes(s, settings.DEFAULT_CHARSET)
  File "****/.venv/lib/python3.8/site-packages/django/utils/encoding.py", line 97, in force_bytes
    return s.decode('utf-8', errors).encode(encoding, errors)
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte

----------------------------------------------------------------------
Ran 1 test in 0.007s

FAILED (errors=1)

force_bytesという関数の中でエラーが発生しているようです。force_bytesのソースコードを見てみましょう。

 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
def force_bytes(s, encoding='utf-8', strings_only=False, errors='strict'):
    """
    Similar to smart_bytes, except that lazy instances are resolved to
    strings, rather than kept as lazy objects.

    If strings_only is True, don't convert (some) non-string-like objects.
    """
    # Handle the common case first for performance reasons.
    if isinstance(s, bytes):
        if encoding == 'utf-8':
            return s
        else:
            return s.decode('utf-8', errors).encode(encoding, errors)
    if strings_only and is_protected_type(s):
        return s
    if isinstance(s, memoryview):
        return bytes(s)
    return str(s).encode(encoding, errors)
https://github.com/django/django/blob/2.2.17/django/utils/encoding.py#L85-L102

本来は94行目のif encoding == 'utf-8':で真と判定されて入力値sはそのままリターンされるはずですが、DEFAULT_CHARSET="UTF-8"にしたために文字列が一致せず、97行目でs.decodeが呼ばれてUnicodeDecodeErrorが発生してしまいました。

上記テストはDEFAULT_CHARSET="utf-8"であればUnicodeDecodeErrorは発生しません。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import io

from django.test import override_settings, TestCase
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from PIL import Image


class ImageViewTest(TestCase):
    def _generate_image_file(self, filename):
        im = Image.new(mode="RGB", size=(200, 200))
        im_io = io.BytesIO()
        im.save(im_io, "jpeg")
        im_io.seek(0)
        return SimpleUploadedFile(
            filename,
            im_io.read(),
            content_type="image/jpeg",
        )

    @override_settings(DEFAULT_CHARSET="utf-8")
    def test_upload_image(self):
        data = {
            "content": self._generate_image_file("test.jpg"),
        }

        self.client.post("/image/create/", data)