Перейти к содержимому
Ступень 00.2 Python с нуля

Модули, файлы, исключения

Как разложить программу по нескольким файлам и подключать чужой код через import; как читать и записывать текстовые файлы и JSON, чтобы русский и китайский текст не превращался в кракозябры; как программе не падать на ошибках — try/except.

УРОКstage-0/0-2-python/04-files-modules.mdx2 чУрок 5 из 7
После урока вы сможете
  • Разложить код по модулям и пакетам и понимать, где Python ищет то, что вы импортируете
  • Читать и писать текст, JSON и JSONL через open и pathlib, всегда с encoding="utf-8"
  • Объяснить, почему «Привет» превращается ⠫Привет», а «€» — в «â‚¬», и как этого избежать
  • Обрабатывать ошибки через try/except/else/finally и бросать свои исключения с понятным сообщением

Учебная LLM — это не один файл. В репозитории MiniMind, например, есть папки model/ (архитектура), dataset/ (загрузка данных), trainer/ (скрипты обучения) и scripts/. Код раскладывают по модулям и пакетам, а связывает их import.

Данные для обучения лежат в файлах — обычно в JSONL, по одной записи на строку. Эти записи на русском, английском и китайском, а значит, вы обязательно столкнётесь с кодировками. И что-то обязательно пойдёт не так: файла нет, строка обрезана, в тексте попался битый байт. Обучение на миллиардах токенов не должно падать из-за одной плохой строки — для этого есть исключения.

С этим уроком у вас будет полный набор базового Python. Вы сможете написать настоящий скрипт: прочитать корпус, посчитать статистику, сохранить результат и не упасть на грязных данных.

МодульмодульEN moduleФайл .py, который можно импортировать. Не называйте свои файлы как модули стандартной библиотеки (json.py, random.py). — это просто файл .py. Инструкция import находит его, один раз выполняет и даёт доступ к его именам — функциям, классам, переменным. Повторный import того же модуля ничего не выполняет: Python помнит уже загруженные модули (они хранятся в sys.modules).

Проще всего увидеть это на двух своих файлах в одной папке:

# файл greetings.py
def hello(name):
return f"Привет, {name}!"
# файл main.py — лежит рядом, запускаем: python main.py
import greetings # имя файла без .py
print(greetings.hello("Мэй")) # → Привет, Мэй! (имя_модуля.имя_функции)

Так же подключаются модули, которые написали другие люди: из стандартной библиотеки (она идёт вместе с Python) и из пакетов, установленных через pip (урок 0.1.2). Три формы записи:

import math # весь модуль: math.sqrt(2)
from collections import Counter # одно имя из модуля
import numpy as np # псевдоним — так принято для numpy, torch.nn.functional as F и т. п.
print(Counter("мама мыла раму мама".split()).most_common(2)) # → [('мама', 2), ('мыла', 1)]

Counter из стандартного модуля collections делает одной строкой то, что в уроке 0.2.1 занимало цикл со словарём: считает частоты, а most_common(k) возвращает k самых частых элементов. Так выглядит главная польза модулей: готовый проверенный код, который не нужно писать заново.

Где Python ищет модули — по порядку:

  1. Папка запущенного скрипта (или текущая папка в интерпретаторе и Jupyter).
  2. Стандартная библиотека.
  3. Папка site-packages вашего виртуального окружениявиртуальное окружениеEN virtual environment (venv)Отдельная папка со своим Python и своими библиотеками для одного проекта. — туда pip install кладёт пакеты (урок 0.1.2).

Полный список путей — в sys.path.

У каждого модуля есть переменная __name__. При запуске файла как скрипта (python tokenizer.py) она равна "__main__", а при импорте — имени модуля ("mini_llm.tokenizer"). Поэтому код «для ручной проверки» прячут под условие:

mini_llm/tokenizer.py
class CharTokenizer:
...
if __name__ == "__main__":
# выполняется при запуске файла напрямую, но не при import
print("самопроверка:", CharTokenizer("абв").encode("ва"))

Стандартная библиотека, которая нужна в ML

Заголовок раздела «Стандартная библиотека, которая нужна в ML»
МодульЗачем
collectionsCounter (частоты), defaultdict (словарь со значением по умолчанию)
jsonконфиги, датасеты, результаты экспериментов
pathlibпути к файлам, поиск файлов по маске
reрегулярные выражения — мини-язык шаблонов для поиска в тексте («все слова из букв», «все числа»): очистка и разбиение текста (токенизаторы GPT-2 и Qwen режут текст регулярным выражением, правда, через более мощный сторонний пакет regex)
random, mathсэмплирование, логарифмы, экспоненты
time, logging, argparseзамеры скорости, логи, аргументы командной строки скриптов обучения
unicodedataнормализация Юникода (ниже)

ПакетпакетEN packageПапка с модулями (обычно с файлом __init__.py). Так же называют дистрибутивы, устанавливаемые через pip. — папка с модулями. Обычно в ней лежит файл __init__.py: он выполняется при импорте пакета и может «поднять» нужные имена на верхний уровень. Типичная структура маленького проекта:

my_project/
├── train.py # точка входа: python train.py
└── mini_llm/ # пакет
├── __init__.py # from .tokenizer import CharTokenizer
├── tokenizer.py
└── data.py # from .tokenizer import CharTokenizer
train.py
from mini_llm import CharTokenizer # благодаря __init__.py
from mini_llm.data import load_ids # абсолютный импорт: путь от корня проекта

Внутри пакета модули импортируют друг друга относительно: точка в from .tokenizer import ... значит «из этого же пакета». Если запустить такой модуль напрямую (python mini_llm/data.py), Python не поймёт, в каком он пакете: ImportError: attempted relative import with no known parent package. Запускайте его из корня проекта как модуль: python -m mini_llm.data.

Пакеты из pip устроены так же, просто лежат в site-packages. Имя для установки и имя для импорта иногда различаются: pip install scikit-learn → import sklearn, pip install pyyaml → import yaml.

Файл открывают функцией open(путь, режим, encoding=...) и почти всегда — внутри with. Запись with open(...) as f: даёт открытому файлу имя f на время блока с отступом. Когда блок закончится, with сам закроет файл, даже если внутри произошла ошибка. Незакрытые файлы — частая причина потерянных данных: часть записанного может остаться в памяти и не попасть на диск.

from pathlib import Path
Path("data").mkdir(exist_ok=True) # папка data/ должна существовать (подробнее — в разделе о pathlib)
with open("data/corpus.txt", "w", encoding="utf-8") as f:
f.write("Москва — столица России.\n") # write НЕ добавляет перевод строки сам
f.write("Пекин — столица Китая. 🐼\n")
f.write("Paris is the capital of France.\n")
with open("data/corpus.txt", encoding="utf-8") as f: # режим "r" — по умолчанию
for line in f: # строки читаются лениво, по одной
line = line.rstrip("\n") # срезать перевод строки справа
print(len(line), line)
# → 24 Москва — столица России.
# → 24 Пекин — столица Китая. 🐼
# → 31 Paris is the capital of France.
РежимЧто делаетОсторожно
"r"чтение текста (по умолчанию)файла нет → FileNotFoundError
"w"запись текстастирает существующий файл
"a"дозапись в конецудобно для логов и JSONL
"rb", "wb"чтение и запись байтовencoding не указывается

Открытый файл — ленивый итератор, как генератор из урока 0.2.2: for line in f читает файл в 50 ГБ построчно, не заполняя память. Методы f.read() и f.readlines() читают файл целиком — для больших корпусов они не годятся.

Модуль pathlib превращает путь из строки в объект с удобными методами. Оператор / склеивает части пути — класс Path просто определил магический метод __truediv__, который Python вызывает для / (прошлый урок), — и сам ставит правильный разделитель на Windows и Linux:

from pathlib import Path
data_dir = Path("data")
data_dir.mkdir(parents=True, exist_ok=True) # создать папку (и родителей), если её нет
path = data_dir / "corpus.txt"
print(path.name, path.stem, path.suffix) # → corpus.txt corpus .txt
text = path.read_text(encoding="utf-8") # прочитать целиком — для небольших файлов
print(len(text), path.stat().st_size) # → 82 125 символов и байт — числа разные!
# (на Windows байт будет 128: перевод строки там — 2 байта \r\n)
for p in sorted(data_dir.glob("*.jsonl")): # все файлы по маске; rglob — рекурсивно
print(p) # (пока .jsonl-файлов нет — цикл ничего не напечатает)

82 символа, но 125 байт. Почему — в следующем разделе.

В уроке 0.0.2 вы видели, что компьютер хранит текст байтамибайтEN byte8 бит, 256 возможных значений (от 0 до 255). В байтах измеряют размер файлов, памяти и моделей.. В Python это два разных типа: str — текст из символов ЮникодаЮникодEN UnicodeЕдиная таблица символов всех письменностей; каждому символу соответствует номер (code point), например «я» = U+044F, «λ» = U+03BB., bytes — последовательность байтов. Переход между ними — это encode и decode, а правило перевода — кодировкакодировка (символов)EN character encodingСпособ записать символы байтами. UTF-8: латиница — 1 байт, кириллица — 2, китайские иероглифы — 3.. Стандарт де-факто — UTF-8: символ занимает от 1 до 4 байт.

for ch in ["A", "я", "€", "😀"]:
b = ch.encode("utf-8")
print(ch, f"U+{ord(ch):04X}", len(b), b)
# → A U+0041 1 b'A'
# → я U+044F 2 b'\xd1\x8f'
# → € U+20AC 3 b'\xe2\x82\xac'
# → 😀 U+1F600 4 b'\xf0\x9f\x98\x80'

Как читать этот вывод. U+044F — номер символа в Юникоде, записанный в шестнадцатеричной системе (цифры 0–9 и буквы A–F); так номера принято писать в таблицах Юникода. Это тот же номер, что даёт ord: 0x44F = 1103 для «я». Запись b'\xd1\x8f' — значение типа bytes: буква b перед кавычкой значит «это байты, а не текст», а каждое \xd1 — один байт, записанный двумя шестнадцатеричными цифрами. У «я» два байта: d1 и 8f. Переводить шестнадцатеричные числа в уме не нужно: достаточно видеть, сколько байт получилось.

ФразаСимволов (len)Байт в UTF-8Байт на символ
Hello, world!13131.00
Привет, мир!12211.75
🙂🐼🐉3124.00

Почему это важно для LLM. Токенизаторы GPT-2, Qwen и DeepSeek — byte-level BPEбайтовый BPE (byte-level BPE)EN byte-level BPE (BBPE)BPE, у которого базовый словарь — 256 байтов UTF-8. Любой текст кодируется без <unk>. Используется в GPT-2, GPT-4o, Llama 3, Qwen, DeepSeek.: они начинают с 256 возможных байтов и учатся склеивать частые последовательности. Кириллическая буква до склеек — это 2 байта, китайский иероглиф или знак € — 3, эмодзи — 4. Если в данных обучения токенизатора мало русского, склеек для него выучено меньше, и тот же смысл стоит больше токенов, а значит, больше денег за API и меньше полезного текста в окне контекста. Подробно — в модуле 4.2.

Если прочитать байты UTF-8 как текст в другой кодировке, получатся кракозябрыкракозябры (mojibake)EN mojibakeНечитаемый текст из-за декодирования байтов не той кодировкой, например «РџСЂРёРІРµС‚» вместо «Привет».:

print("Привет, мир!".encode("utf-8").decode("cp1251")) # → Привет, РјРёСЂ!
print("Цена: 5 €".encode("utf-8").decode("cp1252")) # → Цена: 5 €
print("Ура 🙂".encode("utf-8").decode("cp1252")) # → Ура 🙂

Первая строка — русская кодировка Windows (cp1251), две другие — западноевропейская (cp1252). Иногда вместо кракозябр будет исключение: байты строки "Москва — столица России.", прочитанные как cp1252, дают UnicodeDecodeError: 'charmap' codec can't decode byte 0x81 in position 5: character maps to <undefined>. Исключение здесь даже лучше: кракозябры молча попадают в датасет, и модель учится на «РџСЂРёРІРµС‚», а об ошибке вы узнаете сразу.

Откуда берётся чужая кодировка? Если не указать encoding, open() берёт кодировку операционной системы. На Linux и macOS это UTF-8, а на Windows — региональная: cp1251 на русской системе, cp1252 на английской и cp936 (GBK) на китайской. Код, который у вас работал на Linux, у коллеги на Windows прочитает корпус как кракозябры. Как распознать и починить кракозябры в настоящей выгрузке из CRM или Excel — в уроке 11.1.1.

Ещё три инструмента для грязных данных из интернета:

  • errors="replace" заменяет нечитаемые байты на � вместо исключения: b"\xd0\x9f\xd1".decode("utf-8", errors="replace") даёт 'П�' (последняя буква обрезана посередине). errors="ignore" их просто выбрасывает.
  • encoding="utf-8-sig" убирает BOM — невидимую метку \ufeff, которую некоторые программы Windows ставят в начало файла. Без этого первый ключ CSV или JSON может оказаться "\ufeffid" вместо "id".
  • unicodedata.normalize("NFKC", s) приводит «одинаковые на вид» символы к одному виду: лигатура fi превращается в две буквы fi, верхний индекс ² — в 2, а неразрывный пробел — в обычный. В китайских текстах так же превращают «широкие» латинские буквы, цифры и знаки препинания в обычные. Подобную нормализацию делают многие токенизаторы (например, в SentencePiece нормализация на основе NFKC включена по умолчанию).

JSON — текстовый формат для словарей и списков: в нём хранят конфиги моделей, разметку, результаты экспериментов. Модуль json переводит Python-объекты в строку и обратно:

import json
record = {"text": "мир", "tokens": [1, 2, 3], "ok": True, "meta": None}
print(json.dumps(record))
# → {"text": "\u043c\u0438\u0440", "tokens": [1, 2, 3], "ok": true, "meta": null}
print(json.dumps(record, ensure_ascii=False))
# → {"text": "мир", "tokens": [1, 2, 3], "ok": true, "meta": null}

По умолчанию json.dumps экранирует всё, что не входит в ASCII (английскую таблицу из урока 0.0.2): заменяет каждый такой символ его номером в виде \u043c (это «м»). Это корректный JSON, но читать его невозможно, а файл раздувается: кириллическая буква вместо 2 байт занимает 6 (\u044f), знак € — 6 вместо 3, а эмодзи — целых 12 вместо 4 (\ud83d\ude42 — это «🙂»). Для русского и китайского всегда передавайте ensure_ascii=False.

С файлами работают json.dump и json.load (без s на конце):

cfg = {"hidden_size": 512, "num_hidden_layers": 8, "vocab_size": 6400}
with open("data/config.json", "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2) # indent — красивые отступы
with open("data/config.json", encoding="utf-8") as f:
assert json.load(f) == cfg

Типы Python и JSON соответствуют не один к одному:

PythonJSONПодвох
dictобъект {...}ключи всегда строки: {1: "a"} вернётся как {"1": "a"}
list, tupleмассив [...]кортеж вернётся списком
str, int, floatстрока, числоfloat("nan") — не стандартный JSON
True, False, Nonetrue, false, null

Поэтому json.loads(json.dumps({"shape": (2, 3), 1: "a"})) даёт {'shape': [2, 3], '1': 'a'} — уже не равно исходному. Ещё одна частая ошибка — одинарные кавычки и запятая в конце: {'a': 1} и {"a": 1,} — не JSON (JSONDecodeError: Expecting property name enclosed in double quotes).

JSONL (JSON Lines) — по одной JSON-записи на строку. В нём хранят почти все датасеты для обучения LLM:

  • файл можно читать потоково, строка за строкой, даже если он больше оперативной памяти;
  • в него можно дописывать записи режимом "a";
  • одна битая строка не портит весь файл — её можно пропустить и посчитать.

Надёжное чтение JSONL — это файл, цикл по строкам и try/except вокруг каждой записи. Подробно try/except разберём в следующем разделе, а пока достаточно знать: если код внутри try бросил ошибку JSONDecodeError, Python не падает, а переходит в блок except и продолжает работу после него.

def read_jsonl(path):
records, bad = [], 0
with open(path, encoding="utf-8") as f:
for lineno, line in enumerate(f, start=1): # start=1 — нумерация строк с единицы, как в редакторе
line = line.strip()
if not line: # пустые строки пропускаем молча
continue
try:
records.append(json.loads(line))
except json.JSONDecodeError as e:
bad += 1
print(f"строка {lineno}: {e}")
return records, bad

Проверим на файле из пяти строк: три корректные, одна пустая и одна обрезанная.

lines = [
'{"text": "Москва — столица России."}',
'{"text": "Пекин — столица Китая. 🐼"}',
'',
'{"text": оборванная строка',
'{"text": "Paris is the capital of France."}',
]
Path("data/sample.jsonl").write_text("\n".join(lines) + "\n", encoding="utf-8")
records, bad = read_jsonl("data/sample.jsonl") # → строка 4: Expecting value: line 1 column 10 (char 9)
print(len(records), bad) # → 3 1
print(records[1]["text"]) # → Пекин — столица Китая. 🐼

Номер строки файла (4) печатаем мы сами, а line 1 column 10 из сообщения JSONDecodeError — позиция внутри этой строки: на 10-м символе разборщик ждал значение (например, текст в кавычках), а нашёл букву «о».

Когда операция не может выполниться, Python бросает исключениеисключениеEN exception: создаёт объект ошибки и прерывает выполнение, пока кто-нибудь его не поймает. Как читать tracebackтрассировка ошибки (traceback)EN tracebackСообщение Python об ошибке — цепочка вызовов, которая к ней привела. Читается снизу вверх. и искать баги, разберём в уроке 0.2.5. Здесь — как обрабатывать ошибки, которые вы ожидаете.

Самый простой случай: int() не может превратить текст в число и бросает ValueError. Перехватим его:

text = "сорок два"
try:
n = int(text) # здесь бросится ValueError
except ValueError as e: # as e — имя для объекта ошибки
print("не число:", e) # → не число: invalid literal for int() with base 10: 'сорок два'
n = 0
print(n) # → 0 — программа не упала и пошла дальше

Полная форма конструкции состоит из четырёх частей:

  • try: — код, который может упасть;
  • except ТипОшибки: — выполняется, если в try возникло исключение этого типа. Блоков except может быть несколько, срабатывает первый подходящий;
  • else: — выполняется, только если исключения не было;
  • finally: — выполняется всегда: и после успеха, и после ошибки. Здесь закрывают ресурсы и сохраняют логи.

А инструкция raise бросает исключение самостоятельно. Все четыре части в одной функции:

def load_config(path):
try:
with open(path, encoding="utf-8") as f:
cfg = json.load(f)
except FileNotFoundError:
print(f"нет файла {path}, беру значения по умолчанию")
cfg = {}
except json.JSONDecodeError as e:
raise ValueError(f"конфиг {path} повреждён: {e}") from e
else:
print("конфиг прочитан") # только если исключения НЕ было
finally:
print("finally выполняется всегда") # закрыть ресурсы, сохранить лог
return cfg
flowchart TD
T["try: код, который может упасть"] -->|"исключения нет"| E["else: продолжение"]
T -->|"FileNotFoundError"| X1["except FileNotFoundError"]
T -->|"JSONDecodeError"| X2["except JSONDecodeError<br/>raise ValueError … from e"]
E --> F["finally: всегда"]
X1 --> F
X2 --> F
F -->|"ошибку обработали"| R["return cfg"]
F -->|"исключение не поймано или брошено заново"| U["исключение летит дальше — к вызвавшему коду"]
Порядок выполнения try / except / else / finally

Правила хорошего тона:

  • Ловите конкретные исключения. except FileNotFoundError:, а не голый except:. Голый except ловит вообще всё, включая опечатку NameError в вашем коде и KeyboardInterrupt — и тогда обучение не остановить через Ctrl+C. Если действительно нужно поймать «любую ошибку» (например, чтобы записать её в лог и пропустить пример), пишите except Exception as e: — он не трогает Ctrl+C — и обязательно печатайте e.
  • Исключения образуют иерархию, и except ловит класс со всеми потомками: json.JSONDecodeError — потомок ValueError, FileNotFoundError — потомок OSError, KeyError и IndexError — потомки LookupError.
  • Бросайте исключения с понятным сообщением: raise ValueError(f"dim={dim} не делится на n_heads={n_heads}"). Конструкция raise ... from e сохраняет исходную причину — в traceback будут видны обе ошибки.
  • Свои исключения — это классы-наследники Exception (наследование из прошлого урока):
class TokenizerError(Exception):
"""Ошибка токенизатора."""
def encode_strict(s, stoi):
unknown = sorted({ch for ch in s if ch not in stoi})
if unknown:
raise TokenizerError(f"символов нет в словаре: {unknown}")
return [stoi[ch] for ch in s]
encode_strict("кот!", {"к": 0, "о": 1, "т": 2}) # → TokenizerError: символов нет в словаре: ['!']

В Python принят стиль EAFP — «проще попросить прощения, чем разрешения»: не проверять заранее, существует ли файл, а попробовать открыть его и поймать FileNotFoundError. Между проверкой и открытием файл может исчезнуть, а try честно обработает любой исход.

Мини-задача: что напечатает load_config для существующего корректного файла и в каком порядке?

Сначала конфиг прочитан (блок else), потом finally выполняется всегда, и функция вернёт словарь. Для отсутствующего файла — сообщение из except FileNotFoundError, затем finally, и вернётся {}.

Соберите мини-конвейер подготовки данных: разберите JSONL с записями на русском, на английском и из одних эмодзи (с битыми строками), определите «язык» каждой записи по преобладающей письменности — каких символов больше: кириллицы, латиницы или эмодзи, — посчитайте документы, символы и байты UTF-8 по группам и сохраните статистику в JSON так, чтобы русский текст и эмодзи в файле остались читаемыми. В браузере файлы тоже работают: они живут во временной памяти страницы и исчезают после перезагрузки.

JSONL-корпус: русский, английский и эмодзи7 тестов
import json
from pathlib import Path


def parse_jsonl(text):
    """Разобрать JSONL (одна JSON-запись на строку) -> (records, bad).

    - пустые строки и строки из одних пробелов пропускаются (это не ошибка);
    - запись годится, если json.loads её разобрал И это словарь, у которого "text" — строка;
    - всё остальное (битый JSON, список, словарь без "text") — плохая строка: bad += 1.
    Возвращает кортеж: (список хороших словарей, число плохих строк).
    """
    # TODO: for line in text.splitlines(): ... try: json.loads(...) except json.JSONDecodeError: ...
    ...


def detect_lang(text):
    r"""«Язык» текста по преобладающей письменности: "ru", "en", "emoji" или "unknown".

    Кириллица: после lower() символ в диапазоне "а"–"я" или "ё".
    Латиница:  после lower() символ в диапазоне "a"–"z".
    Эмодзи:    "\U0001F300" <= ch <= "\U0001FAFF" — основной блок эмодзи (\U и 8 цифр — символ по номеру).
    Остальное (цифры, пробелы, знаки, €) не считается. Если таких символов нет — "unknown".
    При равенстве счётчиков побеждает тот, кто раньше в порядке "ru", "en", "emoji".
    """
    # TODO
    ...


def corpus_stats(records):
    """Статистика по «языкам»: {язык: {"docs": число записей, "chars": символов, "bytes": байт в UTF-8}}.

    Язык записи определяется detect_lang(record["text"]). В результате только встретившиеся языки.
    """
    # TODO: подсказка — len(text.encode("utf-8"))
    ...


def save_json(obj, path):
    """Сохранить obj в JSON-файл: кодировка UTF-8, ensure_ascii=False, indent=2.
    Если папки из пути не существуют — создать их."""
    # TODO: Path(path).parent.mkdir(...), затем open(..., "w", encoding="utf-8") и json.dump(...)
    ...


def load_json(path):
    """Прочитать JSON-файл в кодировке UTF-8 и вернуть объект."""
    # TODO
    ...


# Проверьте себя кнопкой «Запустить»:
SAMPLE = """{"text": "Москва — столица России."}
{"text": "🚀🔥👍"}

{"text": оборванная строка
{"text": "Paris is the capital of France."}"""

result = parse_jsonl(SAMPLE)
print(result)
if result:
    records, bad = result
    print(corpus_stats(records))
Проверь себя6 вопросов
1 Чему равно len("🐼🐉".encode("utf-8"))?
2 Сколько байт в UTF-8 занимает строка "Привет, мир!"?
3 Какие утверждения верны?Можно выбрать несколько ответов
4 Скрипт сохраняет датасет так: json.dump(rows, f). В файле вместо русского текста \u043c\u0438\u0440. Что исправить?
5 Команда python mini_llm/data.py падает с ImportError: attempted relative import with no known parent package. Как запустить правильно?
6 Почему в цикле обучения опасен голый except:?
  • Модуль — файл .py, пакет — папка с модулями и __init__.py. Импорт выполняет модуль один раз; не называйте свои файлы json.py или random.py. Код самопроверки прячьте под if __name__ == "__main__":.
  • Файлы открывайте через with open(..., encoding="utf-8") или Path(...).read_text(encoding="utf-8"). Режим "w" стирает файл, "a" дописывает; for line in f читает лениво.
  • UTF-8: 1 байт на латиницу, 2 на кириллицу, 3 на знак € (и на китайский иероглиф), 4 на эмодзи. Чужая кодировка даёт кракозябры или UnicodeDecodeError.
  • JSON — ensure_ascii=False для русского и китайского; JSONL — одна запись на строку, стандарт датасетов LLM.
  • Ловите конкретные исключения, бросайте понятные, используйте else/finally и raise ... from e.

Дальше: урок 0.2.5 «Ошибки и отладка» — как читать traceback снизу вверх, ставить точки останова и находить баги быстрее, чем они находят вас.