Файлы и аргументы командной строки
Настоящий Python-скрипт читает аргументы командной строки, открывает файлы в with-блоке, который закрывает их даже при исключении, потоково читает большие файлы построчно и завершается кодом возврата, на который реагирует shell.
Ночная задача, которая «работала на моей машине», портит каждое имя с диакритикой в проде. Причина — одна строка: open("names.csv"). На macOS разработчика open по умолчанию брал UTF-8; на Linux-сервере под локалью C — почти-ASCII latin-1, и José превратилось в мусор. Через два дня другой скрипт падает на середине записи и оставляет недосброшенный, обрезанный выходной файл, потому что дескриптор никто не закрыл. Оба бага — один и тот же урок: у файлового и CLI-ввода-вывода в Python есть умолчания, которые тихо различаются на разных машинах, и скрипт, который их игнорирует, — это скрипт, который падает в CI, а не на твоём ноутбуке.
К концу урока ты будешь знать, почему оба продовых бага случаются, в чём однострочный фикс для каждого и как сшить их в скрипт, который ведёт себя одинаково на любой машине.
with: закрытие, которое переживает исключение
open() возвращает файловый объект, держащий ресурс ОС — файловый дескриптор и буфер записи. Если его не закрыть, гниют две вещи. Дескриптор течёт (ОС ограничивает, сколько их можно держать; долгоживущий процесс рано или поздно упирается в Too many open files), а у файла на запись твои буферизованные байты могут так и не дойти до диска — вывод тихо обрезается. Наивный фикс — f.close() в конце, но если что-то между open и close бросит исключение, строка close пропускается, и ты всё равно течёшь. Это тот же провал, что и в JavaScript-коде, который забыл finally.
Инструкция with решает это структурно. open() — это менеджер контекста: он определяет __enter__ (выполняется на входе) и __exit__ (на выходе). Python гарантирует, что __exit__ отработает, когда блок завершается по любой причине — обычный проход, return или брошенное исключение, выходящее наружу. Для файла __exit__ вызывает close(), который сбрасывает буфер и освобождает дескриптор. В этой гарантии весь смысл: ты получаешь корректную очистку без try/finally и без надежды на то, что вспомнишь сам.
# Течёт дескриптор и может обрезать вывод, если transform() бросит исключение:
f = open("out.txt", "w", encoding="utf-8")
f.write(transform(data)) # если тут исключение, close() ниже не выполнится
f.close()
# Правильно: __exit__ выполняется (и закрывает/сбрасывает), даже если transform() бросит:
with open("out.txt", "w", encoding="utf-8") as f:
f.write(transform(data))
# файл закрыт и сброшен здесь, с исключением или без▸Почему это работает
«Закрыт при исключении» не значит «глотает исключение». Простой with выполняет __exit__ (закрывая файл), а затем даёт исключению лететь дальше — очистка происходит, но ошибка всё равно роняет скрипт, что тебе и нужно. Подавляет ошибку только тот менеджер контекста, чей __exit__ явно возвращает истинное значение, а у open() он так не делает. Так что with даёт и гарантированную очистку, и честные падения.
Текстовый режим против бинарного и кодировка, которую надо назвать
Почему это важнее, чем кажется? Потому что твой скрипт будет деплоиться в контейнеры, CI-пайплайны и на машины коллег — ни одну из которых ты не контролируешь. У open() два режима. Текстовый (по умолчанию, "r" / "w") декодирует байты в str при чтении и кодирует str в байты при записи, используя кодировку. Бинарный ("rb" / "wb") даёт сырые bytes без декодирования — то, что нужно для изображений, архивов и любой не-текстовой нагрузки.
Ловушка — кодировка по умолчанию. Когда ты опускаешь encoding=, текстовый режим берёт locale.getpreferredencoding(), а она зависит от локального окружения машины — UTF-8 на типичном Mac, но, возможно, cp1252 на Windows или ascii/latin-1 под голой локалью C в контейнере. Тогда один и тот же файл декодируется по-разному на разных хостах — ровно тот прод-баг из хука. Правило безусловно: для текстовых файлов всегда передавай encoding="utf-8" (или ту кодировку, которой файл реально является). Сделай кодировку явной — и скрипт поведёт себя одинаково везде.
# Читается корректно на любом хосте, независимо от локали:
with open("names.csv", "r", encoding="utf-8") as f:
header = f.readline()
# Бинарно: без декодирования, ты получаешь bytes:
with open("logo.png", "rb") as f:
magic = f.read(8) # b'\x89PNG\r\n\x1a\n'Паттерны чтения: не грузи файл на 4 ГБ в RAM
Как ты читаешь важно не меньше того, что ты читаешь. f.read() возвращает весь файл одной строкой — нормально для маленького конфига, катастрофа для многогигабайтного лога, потому что тянет всё в память разом. f.readlines() так же жаден: он строит список всех строк в памяти. Безопасный по памяти паттерн — итерировать файловый объект напрямую: for line in f: — он потоково отдаёт по одной строке за раз и не держит больше одной строки плюс буфер, так что файл на 4 ГБ обрабатывается в постоянной памяти.
# НЕ ДЕЛАЙ на больших файлах — оба грузят всё в RAM:
data = f.read() # весь файл одной str
lines = f.readlines() # список всех строк
# ДЕЛАЙ — потоково, построчно, постоянная память:
with open("app.log", "r", encoding="utf-8") as f:
for line in f: # по одной строке за раз
if "ERROR" in line:
print(line, end="")pathlib.Path: пути как объекты, а не строки
Манипулировать путями через конкатенацию строк — это способ отгрузить "logs" + "/" + name, который ломается на Windows и сдваивает слэши на краевых случаях. pathlib.Path (современный, с 3.4) моделирует путь как объект с операторами и методами, заменяя большинство старых строковых функций os.path. Оператор / склеивает сегменты переносимо, а Path несёт .exists(), .read_text(), .glob() и компанию.
from pathlib import Path
src = Path("data") / "input.csv" # os.path.join(..) — но читаемо и кроссплатформенно
if src.exists():
text = src.read_text(encoding="utf-8") # open+read+close одним вызовом
out = src.with_suffix(".out.csv") # data/input.out.csvЕсли ты из Node, отображение прямое: path.join становится оператором /, fs.existsSync — это .exists(), а fs.readFileSync(p, "utf8") — это Path(p).read_text(encoding="utf-8").
CLI-ввод: sys.argv, затем переход на argparse
Скрипт получает аргументы из sys.argv — списка, где sys.argv[0] — имя скрипта, а остальное — слова пользователя. Разбирать его руками нормально для разовой задачи, но плохо масштабируется: ни --флагов, ни преобразования типов, ни сообщения об использовании, а выход за границы индекса — это необработанный IndexError.
import sys
# Хрупко: только позиционные, падает на пропущенном аргументе, всё — str
infile = sys.argv[1] # IndexError, если пользователь его забыл
limit = int(sys.argv[2]) # ValueError на плохом вводе, без подсказкиПравильный инструмент в момент, когда у скрипта появляются реальные опции, — argparse (стандартная библиотека). Ты объявляешь позиционные и --флаги, задаёшь им type= для преобразования, а argparse парсит sys.argv, валидирует, преобразует типы, генерирует -h/--help, а при неверном вызове печатает usage в stderr и завершается кодом 2 — всё бесплатно.
import argparse
parser = argparse.ArgumentParser(description="Filter and transform a CSV.")
parser.add_argument("infile", help="path to the input CSV") # позиционный
parser.add_argument("-o", "--out", help="output path (default: stdout)")
parser.add_argument("-n", "--limit", type=int, default=0, # авто-преобразование в int
help="max rows to emit (0 = all)")
args = parser.parse_args() # валидирует, заполняет умолчания, выходит с 2 при ошибке
# args.infile, args.out, args.limit готовы к использованиюГрубо говоря, process.argv плюс библиотека флагов в Node схлопываются здесь в argparse — разбор аргументов, приведение типов и экран --help в одном объявлении.
stdin/stdout/stderr и код возврата, который читает CI
Когда ты пишешь скрипт, тот, кто его запускает, — часто не человек, а cron, Makefile или шаг в GitHub Actions. Хорошо ведущий себя CLI-инструмент участвует в работе shell. Обычный вывод идёт в stdout (print() пишет туда по умолчанию), чтобы его можно было передать по пайпу. Диагностика — ошибки, прогресс, предупреждения — идёт в stderr (print(..., file=sys.stderr)), чтобы не портить данные в пайпе. И скрипт завершается кодом возврата: 0 — успех, любое ненулевое — провал. Shell отдаёт его как $?, цепочки && ветвятся на нём, а CI помечает шаг проваленным при ненулевом — так что код возврата это то, как любой автоматический вызывающий узнаёт, сработал ли твой скрипт.
sys.exit(code) его устанавливает. Передай int как статус; передай строку — Python печатает её в stderr и завершается кодом 1. Возврат из main() без вызова sys.exit завершает с 0. Непойманное исключение завершает кодом 1 и трейсбэком в stderr.
import sys
if not args.infile:
print("error: no input file", file=sys.stderr) # диагностика → stderr
sys.exit(2) # ненулевое → вызывающий видит провал
print(result) # данные → stdout
sys.exit(0) # явный успех| Node / JS | Python | Замечание |
|---|---|---|
fs.readFileSync(p, “utf8”) | open(p, encoding=“utf-8”).read() | Оберни в with; всегда называй кодировку |
createReadStream + разбивка строк | for line in f: | Потоково читает большие файлы в постоянной памяти |
process.argv | sys.argv / argparse | argv[0] — имя скрипта; argparse для реальных флагов |
path.join, fs.existsSync | Path / ”..”, .exists() | pathlib моделирует пути как объекты |
console.error | print(…, file=sys.stderr) | Держи диагностику вне stdout |
process.exit(code) | sys.exit(code) | 0 = ок; ненулевое = провал для shell/CI |
Собери части вместе — и настоящий скрипт это конвейер: разобрать аргументы, прочитать вход, преобразовать, записать выход (файл или stdout) и завершиться статусом, на который вызывающий может ветвиться.
Почему `with open(...)` предпочтительнее, чем `f = open(...)` и потом `f.close()`?
Скрипт вызывает sys.exit(0), хотя файла не было и он напечатал ошибку. Что ломается?
Нужно обработать лог приложения на 6 ГБ, оставив только строки ERROR. Как его читать?
- 01Объясни точно, почему `with open(...)` закрывает файл при брошенном исключении, и что пошло бы не так с обычной парой open()/close().
- 02Что сообщает код возврата скрипта, как его выставить в Python и почему он важнее напечатанного сообщения?
Скрипт промышленного уровня относится к файловому и CLI-вводу-выводу как к тому, что должно вести себя одинаково на каждом хосте, а не только на твоём ноутбуке. Открывай файлы под with-блоком: open() — менеджер контекста, чей __exit__ отрабатывает — закрывая и сбрасывая файл — когда блок завершается по любой причине, включая выходящее наружу исключение, так что ты никогда не утечёшь дескриптор и не отгрузишь обрезанный вывод. Всегда передавай encoding="utf-8" для текста, потому что кодировка по умолчанию зависит от локали машины и порождает целый класс багов «работает на моём Mac, ломается в контейнере»; к бинарному режиму тянись только для не-текстовой нагрузки. Большие файлы читай, итерируя файловый объект построчно, а не через read()/readlines(), которые грузят всё в память. Используй pathlib.Path, чтобы строить пути как объекты оператором / вместо хрупкой конкатенации строк. Бери аргументы через argparse, как только появляются реальные флаги — он парсит, преобразует типы, валидирует и генерирует --help бесплатно, — откатываясь на сырой sys.argv только для одноразовых. Наконец, направляй данные в stdout, а диагностику — в stderr и вызывай sys.exit с ненулевым кодом при провале, чтобы shell, цепочки && и CI могли ветвиться на том, сработал ли твой скрипт. Теперь, когда встретишь скрипт, который в проде молча портит имена или оставляет полузаписанный вывод, — ты знаешь, за какими двумя строками тянуться первым делом.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.