Python файлы 2

В прошлой статье мы узнали, как происходит открытие файлов на уровне ядра операционной системы и познакомились с функцией open() и её параметрами. Теперь настало время как-то взаимодействовать с открытыми файлами. В данной статье рассмотрим методы файловых объектов, научимся считывать данные из файла и записывать их в файл. А также узнаем, как правильно заканчивать работу с файлом, что такое менеджер контекста и чем грозит утечка памяти при работе с файлами.

Давайте закрепим материал из прошлой статьи. В функцию open() можно передавать несколько необязательных параметров, работу каждого из которых мы уже рассмотрели. В уже открытом файле вы можете проверить значение всех этих параметров, используя атрибуты файла.

Атрибуты могут быть следующими:

  1. f.closed – логический признак состояния файла: False, если файл открыт, или True для закрытого файла
  2. f.mode – режим работы с файлом (чтение, запись, добавление в файл и т.д.)
  3. f.name – имя файла
  4. f.newlines – представление новой строки, используемое в файле.
  5. f.encoding – строка с кодировкой файла, если кодировка не используется, то содержит None
  6. f.errors – политика обработки ошибок
  7. f.write_through – логическое значение, указывающее, передаются ли при записи данные в нижележащий двоичный файл без буферизации

Пример вывода всех атрибутов файла продемонстрирован ниже:

f = open('file.txt')

print(f.closed)
print(f.mode)
print(f.name)
print(f.newlines)
print(f.encoding)
print(f.errors)
print(f.write_through)

Данный код выведет следующие строки: False, r, file.txt, None, cp1251, strict, False.

Теперь же перейдём к методам файловых объектов. Для начала просто перечислим и кратко опишем каждый, а затем уже будем подробно разбирать каждую группу методов.

readable() – возвращает True, если файл доступен для чтения

read([n]) – считывает n байт/символов с файла. Если значение не указано, то считывается весь файл

readline([n]) – считывает одну строку ввода длиной до n символов. Если значение не указано, то считывается строка целиком

readlines([size]) – считывает все строки и возвращает их в виде списка строк. Необязательный аргумент size задаёт приблизительное количество символов, которые будут прочитаны из файла до остановки

readinto(buff) – считывает данные сразу в буфер buff, находящийся в памяти

writable() – возвращает True, если файл доступен для записи

write(s) – записывает в файл строку s

writelines(lines) – записывает все строки из итерируемого объекта lines

seekable() – возвращает True, если файл поддерживает позиционирование с произвольным доступом

tell() – возвращает текущий файловый указатель

seek(offset, [whence]) – переносит файловый указатель в новую позицию

flush() – принудительно записывает на диск выходные буферы

truncate([size]) – усекает размер файла до размера не более size

close() – закрывает файл

Как вы уже могли догадаться, эти методы мы разобьем на 4 группы: методы для чтения, для записи, для работы с указателем и специальные методы.

Методы чтения

Начнем мы с методов для чтения данных из файла. Для начала желательно убедиться, что открытый файл вообще доступен для чтения: открыт в нужном режиме.

В этом нам поможет метод readable(). Он возвращает True, если файл открыт в режиме, поддерживающем чтение (например, ‘r’, ‘r+’, ‘rb’), и False в противном случае.

Откроем файл file.txt в режиме r’ и вызовем метод readable():

f = open('file.txt', 'r')

print(f.readable())

В результате получаем логическое значение True. Такие методы обычно используются в условиях, чтобы сначала проверить возможность выполнения нужных операций и лишь затем их выполнять. Это поможет избежать вызова исключений в случае, если файл все же окажется недоступен для чтения.

Представим, что мы случайно открыли файл в режиме записи (‘w’). В условии проверим, возможно ли чтение данных из файла. Если чтение допустимо, то прочитаем данные в переменную data. В противном случае выведем сообщение на экран.

f = open('file.txt', 'w')

# Если чтение разрешено
if f.readable():
    data = f.read()
else:
    print("Чтение запрещено!")
>>>Чтение запрещено!

Здесь мы уже коснулись и следующего метода в этой группе. Метод read([n]) читает все содержимое файла или указанное количество символов (байт, если файл открыт в бинарном режиме). Если параметр n не указан, метод читает весь файл.

В этой статье мы будем работать с файлом, в котором находятся такие три строки:

«Первая строка

Вторая строка

Третья строка»

Давайте прочитаем все содержимое файла методом read():

f = open('file.txt', 'r')

data = f.read()
print(data)

На экране видим следующие строки:

Первая строка
Вторая строка
Третья строка

Пу-пу-пу… Что-то не то, вам не кажется?

Дело в том, что этот файл мы создали в стандартном приложении «Блокнот» Windows. Для кириллицы в нём используется немного другая кодировка – Windows-1251. Это можно проверить в бесплатном приложении Notepad++.

Задание 17 3

У нас есть 2 пути решения этой проблемы: всегда указывать в функции open() параметр encoding со значением utf-8 или перекодировать файл.

Первый путь выглядит так:

f = open('file.txt', 'r', encoding='utf-8')

data = f.read()
print(data)

Теперь вспомните прошлую статью, в которой мы говорили о необходимости всегда указывать кодировку файла!

Но если не хочется всегда указывать кодировку, то можно пойти вторым путём: перекодируем файл. Для этого в Блокноте выбираем «Файл» → «Сохранить как…» и в отрывшемся окне выбираем кодировку ANSI.

Задание 17 4

Теперь можно считать данные из файла и получить нужные строки:

f = open('file.txt', 'r')

data = f.read()
print(data)
>>>Первая строка
Вторая строка
Третья строка

Давайте считаем первые 6 символов из файла. Для этого в метод read() передадим аргумент 6:

f = open('file.txt', 'r')

data = f.read(6)
print(data)

Ожидаемо получаем строку «Первая».

Следующий метод, с которым мы познакомимся, — readline(). Им мы будем пользоваться чаще всего. Он считывает данные из файла построчно до символа новой строки. Если достигнут конец файла, то вызов метода readline() вернёт пустую строку.

Считаем только первую строку из файла:

f = open('file.txt')

data = f.readline()
print(data)
>>>Первая строка

В него также можно передать целое число, обозначающая количество символов, которое необходимо считать.

f = open('file.txt')

data = f.readline(6)
print(data)
>>>Первая

С такой функцией вам может сразу захотеться считать построчно все данные из файла, чтобы работать с каждой строкой индивидуально. Но не спешите это делать.

В Python нет явного указателя на конец файла (как EOF в С++), и считывать данные до конца файла в цикле while, например, не получится. Вы можете, конечно, написать бесконечный цикл и останавливать его, пока не дойдёте до последней строки, которая будет пустой (пустая строка будет восприниматься как False в условии). Но так делать не стоит.

Но в Python зато есть возможность итерации по строкам файла через цикл for (даже без вызова метода readline()). Выглядит это так:

f = open('file.txt')

# Считываем данные построчно
for line in f:
    print(line, end="")
>>>Первая строка
>>>Вторая строка
>>>Третья строка

Не забывайте, что в конце каждой строки стоит символ переноса, так что в print() мы передаём в параметр end пустую строку.

С символом переноса мы еще поборемся при рассмотрении следующего метода файлов.

Метод readlines() читает все строки файла и возвращает их в виде списка. Каждая строка в списке заканчивается символом перевода строки \n.

Продемонстрируем его работу:

f = open('file.txt')

data = f.readlines()
print(data)
>>>['Первая строка\n', 'Вторая строка\n', 'Третья строка']

Теперь у нас каждая строка с символом новой строки \n. Избавиться от него можно, применив метод rstrip() к каждой строке.

f = open('file.txt')

data = f.readlines()

# Удаляем символы новой строки с помощью rstrip()
lines = [line.rstrip() for line in data]

# Выводим строки без символов новой строки
print(lines)
>>>['Первая строка', 'Вторая строка', 'Третья строка']

В метод readlines() можно передать целочисленное значение, что будет, по сути, аналогично передачи значения в прошлые два метода. Но работает он так:

Если в первой строке 13 символов, то readlines(13) вернёт только первую строку. А readlines(14) уже вернёт и первую, и вторую строку.

f = open('file.txt')

data = f.readlines(14)

# Удаляем символы новой строки с помощью rstrip()
lines = [line.rstrip() for line in data]

# Выводим строки без символов новой строки
print(lines)
>>>['Первая строка', 'Вторая строка']

Есть еще один метод для чтения данных из файла — readinto(). Он применим только для файлов, открытых в бинарном режиме. Следовательно, и возвращает этот метод данные в байтовом представлении.

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

Подробно останавливаться на его работе в рамках этой статьи мы не будем.

В конце разбора методов чтения данных из файла давайте сделаем такие выводы:

  1. Если нужно работать с каждой строкой файла, то считываем их в цикле for
  2. Если нужно работать сразу со всеми строками, то лучше считать их методом readlines()
  3. Если нужно считывать заданное количество символов, то используем метод read()

Методы записи

Считывать данные с файла мы научились, теперь переходим к записи в файл. Первое, на что тут стоит обратить внимание — это режим работы с файлом.

Не забывайте, что при использовании режима записи w’, всё его содержимое будет удалено, даже если вы просто запустите код f = open(‘file.txt’, ‘w’)!

Если файл не существует, то он будет создан. Если хотите что-то добавить в файл, то используйте режим a’.

Мы же будем работать все с тем же файлом, строки из него нам не нужны, так что будем использовать режим w’.

Точно так же, как и с чтением, можем использовать метод writable(), чтобы удостовериться, что файл доступен для записи.

Например, забыв изменить режим работы с файлом, можем увидеть строку «Запись запрещена» при выполнении такого кода:

f = open('file.txt')

if f.writable():
    print("Запись разрешена")
else:
    print("Запись запрещена")

Теперь перейдём уже к методам записи. Первый рассмотренный метод у нас будет write(). Все, что делает этот метод, очевидно, — это записывает переданную в него строку в файл.

Запишем строку «Привет!» в наш файл:

f = open('file.txt', 'w')

f.write("Привет!")

Записывать строку в файл можно и с помощью стандартной функции print(). Если вспомнить тему про стандартные потоки из прошлой статьи, то это становится очевидным. Что write(), что print() работают со стандартным потоком вывода. Только по умолчанию write() выводит в файл, а print() — на экран.

Перенаправить поток функции print() в файл можно добавив в её вызов параметр file, в который передаётся файловый объект.

f = open('file.txt', 'w')

print("Привет от print()", file=f)

Но лучше так не делать и пользоваться предоставленным вам методом write().

Еще один метод, позволяющий что-либо записывать в файл — это writelines(). По своей работе он похож на антагониста метода readlines(): записывает в файл строки из списка.

Только не забывайте, что в конце каждой строки вам нужно самостоятельно добавлять символ переноса строки (если нужно).

Давайте вернём в наш файл уже полюбившиеся строки:

f = open('file.txt', 'w')

lines = ["Первая строка\n", "Вторая строка\n", "Третья строка\n"]
f.writelines(lines)

Работа с указателем

Вернёмся к прошлой теме, где мы разбирались с чтением данных. Вот отработал у нас функция readline(), прочитали мы одну строчку. А как Python понимает, какую строку читать дальше? Или как быть, если мы хотим вернуться к какой-то строке в файле и снова с неё поработать?

Здесь на помощь приходят файловые указатели. Можно сказать, что указатель работает как некая закладка. Он показывает позицию, с которой будет происходить следующая операция чтения или записи.

Это чем-то похоже на позицию каретки в пишущих машинках. Позиционируя её, мы выбираем, в каком месте напечатается новый символ при нажатии на клавишу машинки.

Когда вы открываете файл, указатель обычно находится в начале файла (позиция 0). По мере чтения или записи данных указатель перемещается.

Для получения текущего положения указателя используется метод tell(). Давайте выведем его значения после того, как прочитаем первые 10 символов:

f = open('file.txt')

f.read(10)
print(f.tell())
>>>10

Это значение будет полезно только, если вы производите отладку кода и вам нужно контролировать, на какой позиции находится указатель после выполнения определённых команд.

Гораздо более полезным является метод seek(). Он позволяет переместить указатель в нужную позицию в файле. Такой метод может пригодиться, когда вам нужно пропустить часть файла, вернуться к началу или перейти к определенному месту.

Его синтаксис мы рассмотрим подробнее:

file.seek(offset, [whence])

Параметры:

offset — количество байт (или символов, если файл открыт в текстовом режиме), на которое нужно переместить указатель.

whence — точка отсчета для перемещения:

  • 0 (по умолчанию) — отсчет от начала файла.
  • 1 — отсчет от текущей позиции указателя.
  • 2 — отсчет от конца файла.

Помним, что строка у нас содержит 13 символов (14, если считать символ новой строки). Давайте пропустим все эти символы и считаем данные с начала второй строки:

f = open('file.txt')

# Пропускаем 13+1 и начинаем с 15
f.seek(15)
data = f.read()
print(data)
>>>Вторая строка
Третья строка

Специальные методы

Нерассмотренными у нас остались всего 3 метода: flush(), truncate() и close().

Начнём с метода flush(). Это все тот же метод работы с буфером, что и у функции print(). Он позволяет принудительно вывести данные из буфера в нужный поток (в нашем случае в нужный файл).

Зачем это делать? Для того чтобы убедиться, что данные точно будут загружены в файл. Например, это может понадобиться, если вы работаете с критически важными данными и их нужно обязательно записать на диск.

Ваша программа может завершиться с ошибкой или вовсе «вылететь» на каком-то моменте, не успев выгрузить данные из буфера в файл. В таком случае вы сразу вызываете flush(), не дожидаясь момента, когда произойдёт обращение к ядру операционной системы.

Метод truncate() позволяет обрезать файл до указанного размера. Этот метод не будет работать, если файл открыт в режиме только для чтения (r’).

Например, мы можем открыть файл, прочитать первую строку и понять, что как-то и не особо хочется в этом файле видеть что-то кроме неё:

f = open('file.txt', 'r+')

data = f.readline()
f.truncate(15)

И было бы здорово, если бы мы могли сначала открыть этот файл, записать в него что-то, потом закрыть, снова открыть, вызвать метод truncate() и прочитать, что же осталось в файле.

По правде говоря, мы все это время работали с файлами некорректно. Мы их не закрывали. То есть пока работала наша программа, у нас все время оставался открытым поток для работы с файлом. Это очень плохо.

Когда вы открываете файл в Python с помощью функции open(), операционная система выделяет ресурсы для работы с этим файлом, включая файловый дескриптор и буферы для чтения/записи. Если файл не закрыть, эти ресурсы останутся занятыми, что может привести к утечке памяти.

Утечка памяти — это ситуация, когда программа выделяет память для своих нужд, но не освобождает её после завершения работы. В результате память остается занятой, что может привести к исчерпанию доступной памяти и снижению производительности системы.

Ваша операционная система имеет жёсткие ограничения на количество одновременно открытых файловых дескрипторов. Если превысить это количество, то возникнут ошибки при попытке открыть новые файлы.

Также при работе с файлами данные хранятся в буфере. Незакрытый файл все еще занимает место в памяти, которое могло быть использовано другими процессами.

Для закрытия файлов используется метод close(). Теперь можем реализовать пример, о котором мы говорили ранее.

# Шаг 1
f = open('file.txt', 'w')
lines = ["Привет!\n", "Как дела?\n", "Как погода?\n"]
f.writelines(lines)
f.close()

# Шаг 2
f = open('file.txt', 'r+')
print(f'Первая строка: {f.readline()}', end="")
# Обрезка до конца текущей строки
f.truncate(f.tell())
f.close()

# Шаг 3
f = open('file.txt', 'r')
print(f'Оставшиеся данные: {f.read()}', end="")
>>>Первая строка: Привет!
>>>Оставшиеся данные: Привет!

В первом шаге мы записали в файл три строки: ["Привет!\n", "Как дела?\n", "Как погода?\n"]. Во втором шаге открываем файл в режиме чтения и записи, считываем первую строку и обрезаем файл до текущей позиции указателя. В итоге мы имеем всего одну строку в файле.

На практике вы, конечно, можете всегда вручную вызывать метод close() для закрытия файла. Но лучше для этого пользоваться менеджером контекста, о котором и пойдёт речь далее.

Контекстный менеджер

«Контекстные менеджеры в Python — это удивительный механизм, который позволяет гарантировать корректное управление ресурсами и обеспечивать безопасное выполнение кода.» — Гвидо ван Россум

Итак, зачем нам нужен контекстный менеджер? Во-первых, он за нас контролирует открытие и закрытие файлов (и не только, но здесь мы говорим именно про файлы). Во-вторых, при работе с файлами, у нас может возникнуть исключение в процессе выполнения программы. Причем, скорее всего, оно возникнет именно до того вызова метода close().

Из-за такого исключения последовательность выполнения команд может обойти строку с закрытием файла. Следовательно, дескриптор файла останется открытым и ресурсы операционной системы не будут освобождены.

До создания менеджера контекста использовалась такая конструкция для обработки возможных исключений:

f = open('file.txt', 'w')

try:
    f.write("Привет!!")
finally:
    f.close()

Код в блоке finally выполнится в любом случае, даже если возникнет исключение в блоке try (например, если у нас файл открыт не в том режиме). То есть, несмотря ни на что, наш файл в итоге будет закрыт.

Чтобы упростить конструкцию try…finally был придуман менеджер контекста с ключевым словом with. Именно с использованием этой конструкции рекомендуется работать с файлами. Выглядит это так:

with open('file.txt') as f:
    f.write("Привет!!")

То есть инструкция with вызывает функцию open() для открытия файла. При этом можно использовать псевдоним для обращения непосредственно к файловому объекту (as f).

Давайте разберем подробнее работу контекстного менеджера. Контекстный менеджер доступен для объектов класса, в котором определены два метода: __enter__() и __exit__().

При выполнении инструкции with вызывается метод __enter__() у переданного выражения. В нашем примере функция open() возвращает экземпляр класса TextIOWrapper, а его метод __enter__ возвращает self (то есть объект файла).

Когда поток управления покидает блок with любым способом (с исключением или без), вызывается метод __exit__(). В случае с файлами, в этом методе просто вызывается метод close().

Но в других классах __exit__() может принимать три аргумента (type, value, traceback). Если исключение в процессе работы контекстного менеджера не вызывалось, то всем трём аргументам присваивается значение None. В противном случае в них содержатся тип, значение и трассировка, связанные с исключением, которое заставило управление покинуть контекст.

Возвращение True методом __exit__() указывает на то, что выданное исключение было обработано и распространяться далее не должно. Возвращаемое значение None или False приводит к распространению исключения.

Зачем мы тут так подробно разбираем эти методы? Действительно, при работе с файлами нужно просто запомнить следующее: открываем файлы всегда через контекстный менеджер with. Он сам будет следить за закрытием файла и освобождением ресурсов, больше ни о чем нам думать не нужно.

Но, зная то, что суть работы with в вызове методов __enter__() и __exit__(), мы можем использовать это в своих собственных классах.

Вспомните, о чем мы говорили в самом начале раздела про методы записи: «Не забывайте, что при использовании режима записи ‘w’, всё его содержимое будет удалено, даже если вы просто запустите код f = open(‘file.txt’, ‘w’)!».

Согласитесь, будет грустно, если у вас в процессе записи возникнет какое-либо исключение, и вы, мало того, что не запишете полезные данные в файл, так еще и удалите с него прошлые данные?

Давайте создадим свой класс SafeRewrite, который будет поддерживать работу менеджера контекста и безопасно записывать в файл новые данные, только если с ними все в порядке и исключения не возникло. В противном случае, файл не должен изменяться.

Если бы мы работали только с open(), это выглядело бы так:

# Запишем в файл данные (изначальные)
with open('file.txt', 'w', encoding="utf-8") as f:
    f.write("Это изначальные данные. Они пропадут")

# Попробуем записать данные без исключения
with open('file.txt', "w", encoding="utf-8") as f:
    f.write("Запись без исключения. Эта строка останется")

# Запишем данные с исключением
try:
    with open('file.txt', 'w', encoding="utf-8") as f:
        raise Exception("Ошибка при записи файла")
        f.write("Эту строку никто не увидит")
except Exception as e:
    print(f"Произошло исключение: {e}")

# Проверяем содержимое файла
with open('file.txt', 'r', encoding="utf-8") as f:
    print(f.read())

Вот только после исключения в третьем блоке with, файл сразу закроется. А из-за того, что мы его открыли в режиме ‘w’, он окажется пустым, а мы останемся грустить из-за потерянных данных.

Но мы можем написать свой класс, в котором настроим работу менеджера контекста именно так, как нам нужно.

Код будет следующий:

import os
import tempfile


class SafeRewrite:
    def __init__(self, path, mode="w", encoding="utf-8"):
        self.path = path
        self.mode = mode
        self.encoding = encoding

    # Контекст менеджер
    def __enter__(self):
        self.temp_file = tempfile.NamedTemporaryFile(
            self.mode,
            encoding=self.encoding,
            delete=False
        )
        return self.temp_file

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.temp_file.close()
        if exc_type is None:
            # Удаляем исходный файл, если он существует
            if os.path.exists(self.path):
                os.unlink(self.path)
            # Переименовываем временный файл в исходный
            os.rename(self.temp_file.name, self.path)
        else:
            # Если произошло исключение, удаляем временный файл
            os.unlink(self.temp_file.name)

В данном классе нам понадобится модуль tempfile для создания временного файла, в который мы и будем записывать данные. Если исключения не возникнет, то мы заменяем исходный файл на этот временный.

Если же возникнет исключение, то временный файл просто удаляется, а данные в исходном остаются нетронутыми.

Проверить работу нашего класса можно с помощью прошлого кода:

# Запишем в файл данные (изначальные)
with open('file.txt', 'w', encoding="utf-8") as f:
    f.write("Это изначальные данные. Они пропадут")

# Попробуем записать данные без исключения
with SafeRewrite('file.txt', "w", encoding="utf-8") as f:
    f.write("Запись без исключения. Эта строка останется")

# Запишем данные с исключением
try:
    with SafeRewrite('file.txt', "w", encoding="utf-8") as f:
        raise Exception("Ошибка при записи файла")
        f.write("Эту строку никто не увидит")
except Exception as e:
    print(f"Произошло исключение: {e}")

# Проверяем содержимое файла
with open('file.txt', 'r', encoding="utf-8") as f:
    print(f.read())

На таком объемном и практичном примере мы и закончим разбирать работу контекстного менеджера.

Давайте подведём итог всей статьи следующими высказываниями:

  1. Всегда открывайте файл с помощью контекстного менеджера
  2. Не забывайте указывать нужный режим работы с файлом (для чтения, записи или добавления данных в файл)
  3. Всегда следите за кодировкой файла, хорошим тоном будет указывать кодировку при открытии
  4. Для перемещения по файлу пользуйтесь указателем (например, для пропуска части файла или возврата к какой-либо уже пройденной позиции)
  5. В случае необходимости, можете создать собственный класс с контекстным менеджером (с методами __enter__() и __exit__())