1. Characters and Bytes
- Python 3 introduced a sharp distinction between strings of human text and sequences of raw bytes. Implicit conversion of byte sequences to Unicode text is a thing of the past
1.1. Character Issues
- A string is a sequence of characters. The best definition of “character” is Unicode character. Accordingly, the items we get out of a Python 3 str are Unicode characters, it is pretty much the Python 2 unicode type with a new name—not the raw bytes we got from a Python 2 str
- The Unicode standard explicitly separates the identity of characters from specific byte representations:
- The identity of a character—its code point—is a number from 0 to 1,114,111, shown in the Unicode standard as 4 to 6 hex digits with a “U+” prefix, from U+0000 to U+10FFFF. For example, the code point for the letter A is U+0041, the Euro sign is U+20AC. About 13% of the valid code points have characters assigned to them in Unicode 13.0.0, the standard used in Python 3.10.0b4
- The actual bytes that represent a character depend on the encoding in use. An encoding is an algorithm that converts code points to byte sequences and vice versa. The code point for the letter A (U+0041) is encoded as the single byte \x41 in the UTF-8 encoding, or as the bytes \x41\x00 in UTF-16LE encoding. As another example, UTF-8 requires three bytes—\xe2\x82\xac—to encode the Euro sign (U+20AC), but in UTF-16LE the same code point is encoded as two bytes: \xac\x20
- Converting from code points to bytes is encoding; converting from bytes to code points is decoding
1 | s = 'café' |
1.2. Byte Essentials
- The new binary sequence types are unlike the Python 2 str in many regards. There are two basic built-in types for binary sequences: the immutable bytes type introduced in Python 3 and the mutable bytearray, added way back in Python 2.6. Each item in bytes or bytearray is an integer from 0 to 255, and not a one-character string like in the Python 2 str. However, a slice of a binary sequence always produces a binary sequence of the same type—including slices of length 1
- Although binary sequences are really sequences of integers, their literal notation reflects the fact that ASCII text is often embedded in them. Therefore, four different displays are used, depending on each byte value:
- For bytes with decimal codes 32 to 126—from space to ~ (tilde)—the ASCII character itself is used
- For bytes corresponding to tab, newline, carriage return, and , the escape sequences \t, \n, \r, and \\ are used
- If both string delimiters ‘ and “ appear in the byte sequence, the whole sequence is delimited by ‘, and any ‘ inside are escaped as \‘
- For other byte values, a hexadecimal escape sequence is used (e.g., \x00 is the null byte)
- Both bytes and bytearray support every str method except those that do formatting (format, format_map) and those that depend on Unicode data, including casefold, isdecimal, isidentifier, isnumeric, isprintable, and encode. This means that you can use familiar string methods like endswith, replace, strip, translate, upper, and dozens of others with binary sequences—only using bytes and not str arguments. In addition, the regular expression functions in the re module also work on binary sequences, if the regex is compiled from a binary sequence instead of a str. Since Python 3.5, the % operator works with binary sequences again
- Binary sequences have a class method fromhex, which builds a binary sequence by parsing pairs of hex digits optionally separated by spaces. The other ways of building bytes or bytearray instances are calling their constructors with:
- A str and an encoding keyword argument
- An iterable providing items with values from 0 to 255
- An object that implements the buffer protocol (e.g., bytes, bytearray, memoryview, array.array) that copies the bytes from the source object to the newly created binary sequence
1 | s = 'café' |
1.3. Basic Encoders/Decoders
- The Python distribution bundles more than 100 codecs (encoder/decoders) for text to byte conversion and vice versa. Each codec has a name, like ‘utf_8’, and often aliases, such as ‘utf8’, ‘utf-8’, and ‘U8’, which you can use as the encoding argument in functions like open(), str.encode(), bytes.decode(), and so on. There are some representative encodings:
- latin1 a.k.a. iso8859_1: It is the basis for other encodings, such as cp1252 and Unicode itself
- cp1252: A useful latin1 superset created by Microsoft, adding useful symbols like curly quotes and € (euro); some Windows apps call it “ANSI,” but it was never a real ANSI standard
- cp437: The original character set of the IBM PC, with box drawing characters. Incompatible with latin1, which appeared later
- gb2312: Legacy standard to encode the simplified Chinese ideographs used in mainland China; one of several widely deployed multibyte encodings for Asian languages
- utf-8: The most common 8-bit encoding on the web
- utf-16le: One form of the UTF 16-bit encoding scheme; all UTF-16 encodings support code points beyond U+FFFF through escape sequences called “surrogate pairs”
| char. | code point | ascii | latin1 | cp1252 | cp437 | gb2312 | utf-8 | utf-16le |
|---|---|---|---|---|---|---|---|---|
| A | U+0041 | 41 | 41 | 41 | 41 | 41 | 41 | 41 00 |
| ¿ | U+00BF | * | BF | BF | A8 | * | C2 BF | BF 00 |
| Ã | U+00C3 | * | C3 | C3 | * | * | C3 83 | C3 00 |
| á | U+00E1 | * | E1 | E1 | A0 | A8 A2 | C3 A1 | E1 00 |
| Ω | U+03A9 | * | * | * | EA | A6 B8 | CE A9 | A9 03 |
| ڿ | U+06BF | * | * | * | * | * | DA BF | BF 06 |
| “ | U+201C | * | * | 93 | * | A1 B0 | E2 80 9C | 1C 20 |
| € | U+20AC | * | * | 80 | * | * | E2 82 AC | AC 20 |
| ┌ | U+250C | * | * | * | DA | A9 B0 | E2 94 8C | 0C 25 |
| 气 | U+6C14 | * | * | * | * | C6 F8 | E6 B0 94 | 14 6C |
| 氣 | U+6C23 | * | * | * | * | * | E6 B0 A3 | 23 6C |
| 𝄞 | U+1D11E | * | * | * | * | * | F0 9D 84 9E | 34 D8 1E DD |
2. Encode/Decode Problems
- Although there is a generic UnicodeError exception, the error reported by Python is usually more specific: either a UnicodeEncodeError or a UnicodeDecodeError. Loading Python modules may also raise SyntaxError when the source encoding is unexpected
2.1. Coping with UnicodeEncodeError
- Most non-UTF codecs handle only a small subset of the Unicode characters. When converting text to bytes, if a character is not defined in the target encoding, UnicodeEncodeError will be raised, unless special handling is provided by passing an errors argument to the encoding method or function
1 | city = 'São Paulo' |
2.2. Coping with UnicodeDecodeError
- Not every byte holds a valid ASCII character, and not every byte sequence is valid UTF-8 or UTF-16; therefore, when you assume one of these encodings while converting a binary sequence to text, you will get a UnicodeDecodeError if unexpected bytes are found
- On the other hand, many legacy 8-bit encodings like ‘cp1252’, ‘iso8859_1’, and ‘koi8_r’ are able to decode any stream of bytes, including random noise, without reporting errors. Therefore, if your program assumes the wrong 8-bit encoding, it will silently decode garbage. Garbled characters are known as gremlins or mojibake
1 | octets = b'Montr\xe9al' |
2.3. SyntaxError When Loading Modules
- UTF-8 is the default source encoding for Python 3 across all platforms, just as ASCII was the default for Python 2. If you load a .py module containing non-UTF-8 data and no encoding declaration, you get a SyntaxError. To fix this problem, add a magic coding comment at the top of the file
1 | # save ola.py with GBK encoding |
2.4. Discover the Encoding
- You can’t find the encoding of a byte sequence. You must be told. Some communication protocols and file formats, like HTTP and XML, contain headers that explicitly tell us how the content is encoded. You can be sure that some byte streams are not ASCII because they contain byte values over 127, and the way UTF-8 and UTF-16 are built also limits the possible byte sequences
- Human languages also have their rules and restrictions, once you assume that a stream of bytes is human plain text, it may be possible to sniff out its encoding using heuristics and statistics. Chardet is a Python library to guess one of more than 30 supported encodings
1 | import chardet |
- The way UTF-8 was designed, it’s almost impossible for a random sequence of bytes, or even a nonrandom sequence of bytes coming from a non-UTF-8 encoding, to be decoded accidentally as garbage in UTF-8, instead of raising UnicodeDecodeError. The reasons for this are that UTF-8 escape sequences never use ASCII characters, and these escape sequences have bit patterns that make it very hard for random data to be valid UTF-8 by accident. So if you can decode some bytes containing codes > 127 as UTF-8, it’s probably UTF-8
- A Byte Order Mark (BOM) is a special sequence of bytes placed at the beginning of a text file to indicate the byte order (endianness) for multi-byte encodings such as UTF-16. Since UTF-16 stores each character using two bytes, computers may store those bytes in little-endian or big-endian order, and the BOM tells the decoder which order to use. In Python, the default utf_16 encoding automatically includes a BOM, while utf_16le and utf_16be explicitly specify the byte order and therefore do not include one
1
2
3
4
5
6 s = 'El Niño'
# BOM (b'\xff\xfe') indicates little-endian UTF-16
print(s.encode('utf_16')) # b'\xff\xfeE\x00l\x00 \x00N\x00i\x00\xf1\x00o\x00'
print(s.encode('utf_16le')) # b'E\x00l\x00 \x00N\x00i\x00\xf1\x00o\x00'
print(list(s.encode('utf_16le'))) # [69, 0, 108, 0, 32, 0, 78, 0, 105, 0, 241, 0, 111, 0]
print(list(s.encode('utf_16be'))) # [0, 69, 0, 108, 0, 32, 0, 78, 0, 105, 0, 241, 0, 111]
3. Handling Text Files
- The best practice for handling text I/O is the “Unicode sandwich”: Decode bytes to str as early as possible when reading input; Work only with str in the business logic of the program; Encode str to bytes as late as possible when writing output
- The open() built-in does the necessary decoding when reading and encoding when writing files in text mode, so all you get from my_file.read() and pass to my_file.write(text) are str objects
1 | fp = open('cafe.txt', 'w', encoding='utf_8') |
3.1. Beware of Encoding Defaults
- Several settings affect the encoding defaults for I/O in Python. The default may be UTF-8 on macOS/Linux, but may be a legacy code page such as cp1252 on Windows, and the behavior may also change when stdin/stdout/stderr are redirected. The best advice about encoding defaults is: do not rely on them. Always be explicit about encodings in your programs
1 | import locale |
4. Normalizing Unicode for Reliable Comparisons
- String comparisons are complicated by the fact that Unicode has combining characters: diacritics and other marks that attach to the preceding character, appearing as one when printed. For example, placing U+0301 after “e” renders “é”. In the Unicode standard, sequences like ‘é’ and ‘e\u0301’ are called “canonical equivalents”. But Python sees two different sequences of code points, and considers them not equal
- The solution is unicodedata.normalize(). The first argument to that function is one of four strings:
- NFC: Normalization Form Canonical Composition, composes the code points to produce the shortest equivalent string
- NFD: Normalization Form Canonical Decomposition, expanding composed characters into base characters and separate combining characters
- NFKC: Normalization Form Compatibility Composition, replacing compatibility characters (some characters appear more than once for compatibility with preexisting standards) with preferred representations, even if there is some formatting loss
- NFKD: Normalization Form Compatibility Decomposition, similar to NFKC
- Keyboard drivers usually generate composed characters, so text typed by users will be in NFC by default. NFC and NFD are safe for most comparisons. NFKC and NFKD may replace characters with visually or semantically related alternatives, so they can lose or distort information. But they can produce convenient intermediate representations for searching and indexing
1 | from unicodedata import normalize, name |
4.1. Case Folding
- Case folding is essentially converting all text to lowercase, with some additional transformations. It is supported by str.casefold(). For any string s containing only latin1 characters, s.casefold() produces the same result as s.lower(), with only two exceptions—the micro sign ‘µ’ and the German Eszett ‘ß’. But casefold() is more aggressive and is better for case-insensitive Unicode comparisons
1 | from unicodedata import name |
4.2. Utility Functions for Text Matching
- NFC is the best normalized form for most applications. str.casefold() is the way to go for case-insensitive comparisons. We can combine them into small utility functions for Unicode string matching
1 | from unicodedata import normalize |
4.3. Taking Out Diacritics
- Removing diacritics is not a proper form of Unicode normalization, because it may change the meaning of words. But it can be useful for searching, indexing, and building readable URLs. The basic idea is: decompose text with NFD, remove combining marks, then recompose with NFC
1 | import unicodedata |
4.4. Sorting Unicode Text
- Python sorts sequences of any type by comparing the items in each sequence one by one. For strings, this means comparing the code points. Unfortunately, this produces unacceptable results for non-ASCII characters. The standard way to sort non-ASCII text in Python is to use the locale.strxfrm function which transforms a string to one that can be used in locale-aware comparisons. However, there are some caveats:
- You need to set locale before using locale.strxfrm as the key when sorting
- Locale settings are global, calling setlocale in a library is not recommended. Your application or framework should set the locale when the process starts, and should not change it afterward
- The locale must be installed and correctly implemented by the OS, so locale-aware sorting can be hard to deploy reliably
1 | fruits = ['caju', 'atemoia', 'cajá', 'açaí', 'acerola'] |
5. The Unicode Database
- The Unicode standard provides a database—in the form of several structured text files—that maps code points to character names and metadata, such as whether a character is printable, a letter, a decimal digit, or another numeric symbol. That’s how the str methods isalpha, isprintable, isdecimal, and isnumeric work. str.casefold also uses information from a Unicode table
1 | import re |
6. Dual-Mode str and bytes APIs
- Some standard library functions accept both str and bytes arguments, but behave differently depending on the type. The re and os modules are important examples
1 | import re |
References
- Fluent Python