Работа с файлами с помощью библиотеки pathlib

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

Главная особенность pathlib в том, что с его помощью вы можете использовать небольшой микро язык для работы с путями и файлами. С новой библиотекой вместо сложных конструкций которые склеивали части строк как это было в os.path вы можете использовать знак деления / для указания части пути. И полученный объект обладает необходимым множеством методов упрощающих решение задач для которых раньше надо было использовать множество разрозненных библиотек.

Путь к текущему файлу и папке

Во время работы с файлами надо знать несколько вещей. Первое, это то что когда вы запускаете интерпретатор, то когда он работает, то находится в какой-то папке, она еще называется текущая рабочая папка или current working dir. И она не всегда совпадает с папкой в которой находится скрипт который вы выполняете. Например, пусть у вас есть такая структура на диске:

~/project/
~/project/src/
~/project/src/script.py

Попробуем поработать с таким script.py:

with open("file.txt", "wt) as f:
    f.write("some text")

Если вы откроете терминал перейдете в папку ~/project/ и запустите такую команду python src/script.py файл file.txt создастся в папке ~/project/, потому что то вашей текущей рабочей папкой будет ~/project/, а не ~/project/src/ в которой находится файл script.py:

~/project/ $ python src/script.py

После запуска даст такую структуру:

~/project/
~/project/file.txt
~/project/src/
~/project/src/script.py

Если перейти в папку src и выполнить похожую команду, то файл file.txt создастся в ~/project/src/:

~/project/ $ rm file.txt
~/project/ $ cd src
~/project/src/ $ python script.py

Новый результат:

~/project/
~/project/src/
~/project/src/file.txt
~/project/src/script.py

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

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

__file__ не доступна в интерактивном режиме!

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

>>> __file__
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name '__file__' is not defined

Чтобы проверить это сохраните на диске файл aboutme.py с таким содержимым:

print(__file__)

Результат выполнения:

$ python aboutme.py 
aboutme.py

Построение пути

Когда мы разбирались с базовыми типами, то выяснили, что оператор / не позволяет делить строки одну на другую. Но путь который создается с помощью конструктора Path из библиотеки pathlib благодаря особой внутренней магии позволяет превратить путь к файлу на диске в python объекты, которые уже поддерживают эту операцию. А кроме этого олучить доступ к свойствам объектов которые связаны с файлами на диске и методами которые позволяют делать с ними манипуляции.

Давайте построим начальные пути относительно текущего файла:

from pathlib import Path

print("Путь к текущему файлу", Path(__file__))
print("Абсолютный путь к текущему файлу", Path(__file__).absolute())

print(
    "Директория в которой расположен текущий файл с помощью относительного пути",
    Path(__file__) / '..'
)
print("Преобразование в полный путь", Path(Path(__file__) / '..').resolve())
print("Обращение к родительскому пути от текущего файла", Path(__file__).parent)

# Папка data находящаяся в той же папке где и текущий файл
print("Директория в рядом с текущим файлом", Path(__file__) / '..' / 'data')

Путь не обязательно строить от текущего файла, можно просто передать строку с полным путем в конструктор и она превратится в полноценный Path-объект:

>>> from pathlib import Path
>>> course = Path('/Users/xen/Dev/python-course')
>>> course
PosixPath('/Users/xen/Dev/python-course')

И, конечно, в качестве отправной точки можно использовать встроенные в Path методы которые указывают на текущую рабочую директорию или на домашнюю директорию текущего пользователя:

>>> Path.cwd()
PosixPath('/Users/xen/Dev/python-course/1-beginner/docs/07-files')
>>> Path.home() / 'Dev/python-course'
PosixPath('/Users/xen/Dev/python-course')

До появления pathlib основной способ объединения путей была функция join модуля os.path, ее синтаксис позволял строить пути объединяя список строк:

>>> Path.home().joinpath('Dev', 'python-course', '00-samples.txt')
PosixPath('/Users/xen/Dev/python-course/00-samples.txt')

Отличия в Windows

В Windows для указания пути к файлам используется обратная наклонная черта (обратный слэш) \. Поскольку символ \ имеет специальное значение в строках, то можно использовать необрабатываемую строку с префиксом r''. Или записывать строки используя прямую наклонную черту. Например эти варианты равнозначны pathlib.Path(r'C:\Users\xen\project\file.txt') и pathlib.Path('C:/Users/xen/project/file.txt').

Создание Path объекта вместо PosixPath создаст WindowsPath. Для большинства операций с файлам это не существенная разница и это отличие проще воспринимать как особенности внутренней реализации. Другое дело когда вам действительно надо использовать особые возможности операционных систем.

Если попытаться создать WindowsPath объект самостоятельно под другой операционной системой, то вы получите ошибку типа такой:

>>> from pathlib import WindowsPath
>>> WindowsPath(r'C:\Users\xen\project\file.txt')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/xen/.pyenv/versions/3.7.2/lib/python3.7/pathlib.py", line 997, in __new__
    % (cls.__name__,))
NotImplementedError: cannot instantiate 'WindowsPath' on your system

Операции с путями и строками

Обратите внимание, что операция / применяется не к строке, а к объекту Path. Например вот конструкция:

p = Path(__file__) / '..' / 'data' / 'subfolder' / 'file.txt'

Интерпретатор ее разбирает следующим образом:

  • Вызывает конструктор объекта Path() который получает в качестве параметра текущий файл в переменной __file__.
  • Создается объект типа Path.
  • К нему применяется операция деления которая получает левый операнд строку ... Объект Path умеет обрабатывать такие операции и результатом ее выполнения становится создание нового объекта типа Path который объединяет путь указывавший на __file__ и строку ...
  • Так продолжается до тех пор пока не построится вся цепочка, то есть за время выполнения этой строки создалось целых 5 объектов Path (первый вызов и потом на каждый знак /). И финальный объект уже присваивается переменной p.

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

>>> Path.cwd() / '..' / '..'.resolve()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'str' object has no attribute 'resolve'

Вместо этого надо сначала получить Path, а потом уже вызывать его метод:

>>> Path(Path.cwd() / '..' / '..').resolve()
PosixPath('/Users/xen/Dev/python-course/1-beginner')

Или:

>>> p = Path.cwd() / '..' / '..'
>>> p.resolve()
PosixPath('/Users/xen/Dev/python-course/1-beginner')

Красивый и удобный интерфейс появился только недавно. Большинство стандартных модулей были написаны во времена когда библиотеки pathlib еще не было. И они ожидают строку в качестве параметра. Все эти прекрасные удобства работы с путями как с объектами в какой-то момент окажутся бесполезными! Я думаю постепенно мир разработчиков будет адаптироваться к более удобной библиотеке, но все еще есть много приложений которые поддерживают более старые версии Python, поэтому они предпочитают работать со путями как со строками. В таких случаях Path объект нужно преобразовать в строку:

>>> str(Path.home().joinpath('Dev', 'python-course'))
'/Users/xen/Dev/python-course'

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

>>> p = Path.cwd() / '..' / '..'
>>> str(p)
'/Users/xen/Dev/python-course/1-beginner/docs/07-files/../..'
>>> str(p.resolve())
'/Users/xen/Dev/python-course/1-beginner'

Чтение и запись файлов

И вот мы подошли к кульминации. Путь построенный с помощью pathlib позволяет не просто строить путь, но и непосредственно работать с ними. У объекта Path есть метод open() который работает так же как и обычная функция открытия файла. Я испытал огромный воссторг когда первый раз осознал, что к пути можно просто дописать .open(), это сэкономило огромное количество лишних строк кода.

Вот окончательный пример программы которая откроет наш, ставший уже стандартным, файл turing_paper_1936.txt, но в этот раз без проблем с определением текущей рабочей папки (reader_pl.py):

from pathlib import Path

paper = Path(__file__).parent / "turing_paper_1936.txt"
with paper.open("rt") as f:
    for line in f:
        print(line, end="")

Для простоты помимо метода .open() который внутри вызывает встроенную функцию open() еще есть несколько функций с более понятными именами упрощающими чтение кода:

  • .read_text() — открывает файл на который указывает путь в текстовом режиме и возвращает содержимое как строку.
  • .read_bytes() — открывает файл в бинарном режиме и возвращает содержимое как объект bytestring.
  • .write_text() — для записи в файл в текстовом виде.
  • .write_bytes() — открывает файл в бинарном виде для записи бинарных данных.

Смотрите насколько более читаемый и изящный код получается в результате:

from pathlib import Path

paper = Path(__file__).parent / "turing_paper_1936.txt"
print(paper.read_text())

Следите за памятью

Обратите внимание, что методы .read_text() и .read_bytes() сразу считывают все содержимое файла в память, что может быть не самым оптимальным вариантом если файлы большие или достаточно их обработать как поток.

Дополнительные операции над файлами

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

>>> from pathlib import Path
>>> p = Path.cwd() / 'turing_paper_1936.txt'
>>> p.exists()
True
>>> new = Path.cwd() / 'new_file.txt'
>>> new.exists()
False
>>> Path.cwd().is_dir()
True
>>> p.is_file()
True

Очень часто используемый метод .exists() проверяет есть ли такой файл на диске, методы .is_file() и .is_dir() проверяют путь на то является ли он файлом или директорией соответственно.

Копирование файла требует чуть более сложной операции:

>>> new.write_text(p.read_text())
1082

В процессе этой операции содержимое файла загрузилось в память, а потом записалось на диск. Переименовывание .rename() и перемещение .replace() файла надо делать аккруратно, потому что если вы попытаетесь переименовать в файл который уже существует, то старый будет перезаписан новым содержимым:

>>> temp = Path.cwd() / 'temp.txt'
>>> if not temp.exists(): new.rename(temp)
...
>>> temp.replace("delete-me.txt")
>>>

Обратите внимание, что объект типа Path не привязан к файлу, а только является объектом. Поэтому переименовывание или перемещение файла не изменит старую переменную, а значит переменная new все еще указывает на уже несуществующий файл.

Для того чтобы удалить экспериментальный файл надо вызвать метод .unlink(). Повторный вызыов метода для уже удаленного файла вызовет сообщение об ошибке:

>>> f_to_del = Path.cwd() / "delete-me.txt"
>>> f_to_del.unlink()
>>> f_to_del.unlink()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/xen/.pyenv/versions/3.7.2/lib/python3.7/pathlib.py", line 1277, in unlink
    self._accessor.unlink(self)
FileNotFoundError: [Errno 2] No such file or directory: '/Users/xen/Dev/python-course/1-beginner/docs/07-files/delete-me.txt'

Работа с директориями

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

>>> [child for child in Path('.').iterdir()]
[PosixPath('new_copy.py'), PosixPath('aboutme.py'),
PosixPath('reader_pl.py'), PosixPath('1-open.md'),
PosixPath('reader2.py'), PosixPath('reader.py'),
PosixPath('2-with.md'), PosixPath('turing_paper_1936.txt'),
PosixPath('work'), PosixPath('3-pathlib.md'),
PosixPath('showpath.py')]

Для поиска файлов по маске есть специальный язык запросов по шаблону на английском называющийся wildcard pattern или glob pattern.

С помощью этого языка можно сформировать шаблон по которому будут искаться файлы в директориях. Чаще всего употребляются два символа * (звездочка) для обозначения любого количества символов (включая ноль) и ? (знак вопроса) для обозначения любого одиночного символа. Чтобы найти все текстовые файлы в папке можно использовать такой шаблон *.txt. Для более точного поиска используются квадратные скобки в которых указываются группы возможных символов. Например для поиска всех файлов с именами readme.txt или Readme.txt можно использовать такой шаблон [Rr]eadme.txt. Для латинских символов не обязательно их все перечислять в скобках если они входят в какой-то диапазон, а можно его указать со знаком -, например так paper[0-9]*.txt найдет все документы начинающиеся состроки paper, после которой идут любое количество чисел и с расширением .txt.

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

pathlib тоже поддерживает поиск по маске:

>>> sorted(Path('.').glob('*.py'))
[PosixPath('aboutme.py'), PosixPath('new_copy.py'),
PosixPath('reader.py'), PosixPath('reader2.py'), 
PosixPath('reader_pl.py'), PosixPath('showpath.py')]

Создание папки осуществляется методом .mkdir(mode=0o777, parents=False, exist_ok=False). Параметр mode устанавливает режим доступа для файловых систем которые его поддерживают. Если использовать параметр parents то метод создаст всю цепочку папок вложеных друг в друга. Если при попытке создать папку окажется, что она уже существует, то вы получите ошибку FileExistsError, если вам не важно существовала ли папка до этого, то укажите параметр exist_ok=True:

>>> target = Path('.') / 'work' / 'on' / 'some' / 'data'
>>> target.mkdir(parents=True)
>>> target.mkdir(parents=True)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/xen/.pyenv/versions/3.7.2/lib/python3.7/pathlib.py", line 1241, in mkdir
    self._accessor.mkdir(self, mode)
FileExistsError: [Errno 17] File exists: 'work/on/some/data'
>>> target.mkdir(parents=True, exist_ok=True)

Удаления директорий осуществяется методом rmdir():

>>> Path(Path('.') / 'work').rmdir()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/xen/.pyenv/versions/3.7.2/lib/python3.7/pathlib.py", line 1285, in rmdir
    self._accessor.rmdir(self)
OSError: [Errno 66] Directory not empty: 'work'

К сожалению это не сработает если директория не пустая. Если надо удалить не пустую папку со всем содержимым, то для необходимо использовать еще один модуль shutil который тоже был обновлен и поддерживает в качестве параметров Path объекты:

>>> import shutil
>>> shutil.rmtree(Path('.') / 'work')

Ссылки