Text I/O fundamentals: encoding, newlines, io.StringIO
Это первый урок Module 09 — введение в file I/O fundamentals. Production data engineer ежедневно читает и пишет CSV / JSON / log files; ошибки в encoding / newline handling / file lifecycle — массовый источник production bugs. М09 строится pragmatic-DEEP: stdlib API + production pitfalls + cross-links к Storage Formats / Spark / ClickHouse.
Главный invariant Module 09 (carrying milestone-level constraint): в browser challenges мы НЕ читаем реальные файлы. Pyodide MEMFS — in-memory filesystem; реальный disk учащегося browser не видит. Мы используем io.StringIO simulation pattern — оборачиваем строку в file-like объект. Реальный open() появляется только в lesson prose (Markdown rendered as syntax-highlighted text — не выполняется) и в Run-on-Your-Machine callouts (lesson 06).
В этом уроке:
- Why text I/O — production контекст.
open()modes —r/w/a/x/+/btable.- Encoding — UTF-8 (default) / Latin-1 / UTF-16-BOM;
errors='replace'/'ignore'. - Newline handling —
newline=''для CSV (Pitfall — Windows\r\ntranslation). with-statement — context manager recipe — cross-link M06 урок 04.io.StringIOsimulation — Pattern 1 setup для challenges 02/03.- Chunking via generator —
def read_chunks(buf, size): yield buf.read(size)— cross-link M05 урок 02. - Encoding pitfalls — BOM detection, mojibake, surrogate pairs.
Why text I/O — production контекст
Вы читаете CSV из vendor’а, JSON из API response cache, log files после rotation. Каждое из этих действий проходит через text I/O layer — open() + encoding + newline handling. Ошибки на этом layer:
- Mojibake —
'café'превращается в'café'потому что байты0xC3 0xA9(UTF-8 дляé) интерпретированы как Latin-1. UnicodeDecodeError— file объявленencoding='utf-8', но содержит Windows-1251 байты от legacy export.csv.Error: new-line character seen in unquoted field— забылиnewline=''при чтении CSV на Windows.ResourceWarning: unclosed file— забылиwith-statement, file descriptor leak, под нагрузкой OS bound на open files.
Pragmatic-DEEP rule: каждый production-grade file read должен explicit declare encoding и использовать with-statement. Default encoding (locale.getpreferredencoding()) — platform-specific и не портативен между dev / CI / production.
open() modes — r/w/a/x/+/b table
Сигнатура: open(file, mode='r', buffering=-1, encoding=None, errors=None, newline=None). Mode — combinable string:
| Mode | Read | Write | Truncate | Position | Use case |
|---|---|---|---|---|---|
'r' | да | нет | — | start | default — read existing file |
'w' | нет | да | да (overwrite) | start | write new file (creates если absent) |
'a' | нет | да | нет | end | append (creates если absent) |
'x' | нет | да | — | start | exclusive create — FileExistsError если exists |
'r+' | да | да | нет | start | read + write same file (advanced) |
'w+' | да | да | да | start | truncate + read + write (rare) |
'a+' | да | да | нет | end | append + occasional read |
Binary mode — добавь b: 'rb', 'wb', 'ab'. В binary mode возвращает bytes, не str. Используется для:
- compression (
gzip,bz2,lzma) — урок 05; - binary formats (Parquet, ORC, Avro, Arrow IPC) — урок 04;
- raw bytes parsing (image / video / network protocol headers).
В text mode (без b) возвращает str — Python автоматически decode’ит байты согласно encoding parameter.
# Text mode — UTF-8 explicit (production-grade)
with open('users.csv', 'r', encoding='utf-8') as f:
content = f.read() # type: str
# Binary mode — без encoding
with open('image.png', 'rb') as f:
raw = f.read() # type: bytes
# Exclusive create — fails if file exists
with open('config.lock', 'x') as f:
f.write('locked')
Cite docs.python.org/3/library/functions.html#open — full mode table + buffering semantics.
Encoding — UTF-8, Latin-1, UTF-16-BOM
UTF-8 — default Python 3 source encoding (PEP 3120) и dominant standard для text exchange. Variable-width: 1-4 байта на codepoint. ASCII-compatible (ASCII codepoints занимают 1 byte и идентичны pure ASCII).
Latin-1 (a.k.a. ISO-8859-1) — single-byte encoding для Western European symbols. Legacy databases / banking exports / pre-Unicode systems. Не Unicode-complete (не хватает é в proper form, не хватает Cyrillic / CJK).
UTF-16-BOM — Windows artifact. Notepad по default writes UTF-16 LE c BOM 0xFF 0xFE. Excel exports CSV в UTF-16 на Windows. Python автоматически detect’ит BOM если encoding declared 'utf-16', но не если declared 'utf-8' — вы получите BOM character как первый “символ” file.
Recipe — production-grade UTF-8 read с error handling:
def read_text_safe(path: str, encoding: str = 'utf-8') -> str:
"""Read text file with explicit encoding + error reporting."""
try:
with open(path, 'r', encoding=encoding) as f:
return f.read()
except UnicodeDecodeError as e:
# Файл объявлен utf-8 но содержит non-UTF-8 байты
raise ValueError(
f'{path}: not valid {encoding} (byte {e.start}-{e.end}: {e.reason})'
) from e
errors= parameter — strategy для invalid bytes:
| Value | Behavior |
|---|---|
'strict' (default) | raise UnicodeDecodeError — fail loud |
'replace' | invalid bytes → '�' (replacement char ?) — survive bad data |
'ignore' | drop invalid bytes silently — dangerous (data loss without trace) |
'backslashreplace' | \xff → '\\xff' literal — debugging |
'surrogateescape' | invalid bytes → low-surrogate codepoints — lossless round-trip |
Production rule: 'strict' для validation pipelines (catch bad data early); 'replace' для display / log ingestion (don’t crash on legacy junk); никогда 'ignore' без logging — silent data loss.
# Survive mixed-encoding log files
with open('legacy.log', 'r', encoding='utf-8', errors='replace') as f:
for line in f:
print(line.rstrip()) # invalid bytes → ?
Cite docs.python.org/3/library/codecs.html#error-handlers.
Newline handling — newline='' for CSV
Параметр newline= — subtle но production-critical. Default (newline=None) включает universal newlines mode — \r\n, \r, \n все трансформируются в \n при чтении и \n → os.linesep при записи. Удобно для прозы, но:
# WRONG для CSV — universal newlines делает встроенные \r\n внутри quoted field translated
import csv
with open('windows.csv', 'r') as f: # ← newline=None default
reader = csv.reader(f)
for row in reader:
print(row) # _csv.Error: new-line character seen in unquoted field
Правильно — newline='' для CSV (caller перекладывает newline handling на csv module):
import csv
with open('windows.csv', 'r', encoding='utf-8', newline='') as f:
reader = csv.reader(f)
for row in reader:
print(row) # OK
Cite docs.python.org/3/library/csv.html#footnotes — explicit warning о newline=''.
with-statement — context manager recipe (cross-link M06 урок 04)
Без with-statement file descriptor leak — anti-pattern:
# ANTI-PATTERN — leaks descriptor если read() raises
f = open('data.csv', 'r')
content = f.read() # ← exception здесь = no f.close()
f.close()
С with-statement — гарантированный cleanup даже при exception:
# Production — context manager guarantees close
with open('data.csv', 'r', encoding='utf-8') as f:
content = f.read()
# f автоматически closed — даже если read() raises
Cross-link M06 урок 04 (context-manager protocol): open() возвращает io.TextIOWrapper instance, который реализует __enter__ / __exit__ методы. with statement — syntactic sugar над:
f = open('data.csv', 'r', encoding='utf-8')
try:
content = f.read()
finally:
f.close() # ← __exit__ внутри context manager делает то же самое
Recap М06 урок 04 protocol foundation: любой объект с __enter__ + __exit__ — context manager. open() возвращает TextIOWrapper/BufferedReader/FileIO (depending on mode), всё реализует protocol. Это architectural reuse — same pattern что мы выучили для locks, transactions, temp files.
Cite docs.python.org/3/library/io.html#io.TextIOWrapper.
io.StringIO simulation — Pattern 1 setup
В browser challenges Module 09 мы не можем open реальный файл. Pyodide MEMFS — in-memory FS, пустой по default; реальный disk учащегося недоступен (security boundary). Решение: simulate file через io.StringIO:
import io
import csv
# Simulated CSV file — string wrapped в file-like object
csv_data = "name,age,role\nalice,30,dev\nbob,25,qa\n"
buf = io.StringIO(csv_data)
reader = csv.DictReader(buf)
for row in reader:
print(row) # {'name': 'alice', 'age': '30', 'role': 'dev'}
# {'name': 'bob', 'age': '25', 'role': 'qa'}
Что предоставляет io.StringIO:
- file-like API:
.read(),.readline(),.readlines(),.write(...),.seek(0),.tell() - iterability:
for line in buf:— line-by-line как настоящий file - in-memory storage: ничего не записывается на disk
- type:
io.StringIOдляstr;io.BytesIOдляbytes(binary mode equivalent — урок 05)
Production usage: io.StringIO ценен и вне Pyodide — для unit tests (parse строки without temp file), для in-memory pipe между producer и consumer, для capture stdout contextlib.redirect_stdout(buf).
В уроке 02 (CSV) — Pattern 1 challenge py-m09-02-code-1 показывает full canonical use:
import io
import csv
def solve(csv_str: str) -> list[dict]:
buf = io.StringIO(csv_str)
reader = csv.DictReader(buf)
rows = []
for row in reader:
row['age'] = int(row['age']) # csv возвращает str — конвертация — caller's responsibility
rows.append(row)
return rows
Cite docs.python.org/3/library/io.html#io.StringIO.
Chunking via generator (cross-link M05 урок 02)
Для больших файлов (>RAM) f.read() загружает всё в память — OOM. Решение — chunked reading через generator:
import io
from collections.abc import Iterator
def read_chunks(buf: io.IOBase, size: int = 1024) -> Iterator[str]:
"""Yield chunks of `size` chars until EOF."""
while chunk := buf.read(size):
yield chunk
Как это работает:
walrus :=(Python 3.8+) присваивает + проверяет в одной expression;while chunk: ...exits когдаf.read(size)returns empty string (EOF — convention для text I/O);yield chunk— lazy evaluation — caller получает chunk по требованию.
Cross-link M05 урок 02 (generator climax): generator function — функция с yield. CPython создаёт PyGenObject instance — он сохраняет local state (locals + bytecode pointer) между вызовами next(). Это позволяет process файл размером 100GB используя constant memory — только текущий chunk в RAM.
Recipe — process large CSV без OOM:
def total_bytes(path: str) -> int:
"""Count total bytes in file без loading в память."""
with open(path, 'rb') as f:
return sum(len(chunk) for chunk in read_chunks(f, size=1 << 16))
# ↑ 64KB chunks
В уроке 03 (JSON streaming) увидим тот же pattern для JSONL — for line in f: json.loads(line) — generator-based streaming.
Cite PEP 525 — Asynchronous Generators и М05 урок 02 PyGenObject.
Encoding pitfalls — BOM, mojibake, surrogate pairs
BOM (Byte Order Mark)
UTF-8-BOM начинается с байт 0xEF 0xBB 0xBF. Microsoft Notepad / Excel insert это даже когда файл — UTF-8 (BOM не нужен в UTF-8 — byte order не имеет смысла для 1-byte units, но Microsoft tools insert anyway). Эффект:
with open('excel-export.csv', 'r', encoding='utf-8') as f:
print(repr(f.readline())) # 'name,age,role\n'
# ↑ BOM как первый "character"
Решение: explicit encoding='utf-8-sig' — strips BOM при чтении:
with open('excel-export.csv', 'r', encoding='utf-8-sig') as f:
print(repr(f.readline())) # 'name,age,role\n' — BOM stripped
Mojibake
Файл написан UTF-8 ('é' = 0xC3 0xA9), но открыт с encoding=‘latin-1’ — каждый byte интерпретируется отдельно: 0xC3 → 'Ã', 0xA9 → '©'. Result: 'café'. Это mojibake — visually corrupted text, но bytes неповрежденные. Reverse fix: re-encode latin-1 → bytes → decode utf-8.
broken = 'café' # mojibake
fixed = broken.encode('latin-1').decode('utf-8') # 'café'
Surrogate pairs
Symbols вне BMP (Unicode codepoint > U+FFFF — emoji, ancient scripts, math) занимают 4 bytes в UTF-8 и 2 code units в UTF-16. Surrogate pairs — UTF-16 mechanism для кодирования таких codepoints. Python str использует internal representation согласно PEP 393 — flexible width per-string, не UTF-16 surrogates. len('🐍') == 1 — 1 codepoint, не 2.
Production rule: всегда работайте с codepoints (str), не с UTF-16 code units. Если interfacing с JS / Java / Windows API — используйте 'utf-16' encoding в open() или surrogateescape error handler для round-trip preservation.
Что в следующем уроке
Урок 02 — CSV stdlib API (csv.reader, csv.DictReader, dialects, quoting). Code-challenge py-m09-02-code-1 — Pattern 1 (CSV via io.StringIO + csv.DictReader). Cross-course → ClickHouse FORMAT CSV clause + Spark schema inference. Foundation, заложенная в этом уроке (io.StringIO, with-statement, encoding=‘utf-8’), переносится напрямую.
Pragmatic-DEEP принцип: не ULTRA-DEEP’аем кодеки. UTF-8 / Latin-1 / UTF-16 — production necessity, остальные encodings (KOI8-R, Big5, Shift-JIS) — деталь legacy migration; learn on demand.