От переводчика: Привет, хабр! Представляю вашему вниманию перевод статьи Why you should be using pathlib и её продолжения, No really, pathlib is great. Много внимания нынче уделяется таким новым возможностям Python, как asyncio, оператору :=, и опциональной типизации. При этом за радаром рискуют пройти не столь значительные (хотя, := назвать серьёзным нововведением язык не поворачивается), но весьма полезные нововведения в язык. В частности, на хабре статей, посвящённых сабжу, я не нашел (кроме одного абзаца тут), поэтому решил исправить ситуацию.
Когда я открыл для себя тогда еще новый модуль pathlib несколько лет назад, я по простоте душевной решил, что это всего лишь слегка неуклюжая объектно-ориентированная версия модуля os.path
. Я ошибался. pathlib
на самом деле чудесен!
В этой статье я попытаюсь вас влюбить в pathlib
. Я надеюсь, что эта статья вдохновит вас использовать pathlib
в любой ситуации, касающейся работы с файлами в Python.
- Часть 1.
- Часть 2. Ответы на вопросы.
Сравнение os.path
иpathlib
по-честному- Вы не должны беспокоиться о нормализации путей
Звучит классно, но у меня сторонняя библиотека, которая не использует pathlib
!- Но в моей любимой библиотеке есть Path лучше стандартного!
Но ведь pathlib.Path
иstr
не смешиваются, правда?pathlib
слишком медленный- Улучшение читаемости
Начните использовать объекты pathlib.Path
Часть 1.
os.path
неуклюж
Модуль os.path
всегда был тем, что мы использовали когда речь заходила про пути в Python. В принципе, там есть всё, что вам нужно, но часто выглядит это не слишком изящно.
Стоит ли импортировать его так?
import os.path
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TEMPLATES_DIR = os.path.join(BASE_DIR, 'templates')
Или так?
from os.path import abspath, dirname, join
BASE_DIR = dirname(dirname(abspath(__file__)))
TEMPLATES_DIR = join(BASE_DIR, 'templates')
Может быть функция join
имеет слишком общее название, и нам стоит сделать что-то такое:
from os.path import abspath, dirname, join as joinpath
BASE_DIR = dirname(dirname(abspath(__file__)))
TEMPLATES_DIR = joinpath(BASE_DIR, 'templates')
Мне все варианты выше кажутся не слишком удобными. Мы передаём строки в функции, которые возвращают строки, которые мы передаём следующим функциям, работающим со строками. Так уж случилось, что они все содержат пути, но они всё еще всего лишь строки.
Использование строк для ввода и вывода в функциях os.path
весьма неудобное, потому что код приходится читать изнутри наружу. Хотелось бы преобразовать эти вызовы из вложенных в последовательные. Именно это и позволяет сделать pathlib
!
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
TEMPLATES_DIR = BASE_DIR.joinpath('templates')
Модуль os.path
требует вложенных вызовов функций, но pathlib
позволяет нам создавать цепочки последовательных вызовов методов и атрибутов класса Path
с эквивалентным результатом.
Я знаю что вы думаете: стоп, эти объекты Path
— не то же самое, что было раньше, мы больше не оперируем строками путей! К этому вопросу вернёмся позже (подсказка: почти в любой ситуации эти два подхода взаимозаменяемы).
os
перегружен
Классический модуль os.path
предназначен для работы с путями. Но после того как вы что-то хотите сделать с путём (например, создать директорию), вам нужно будет обращаться к другому модулю, часто os
.
os
содержит кучу утилит для работы с файлами и директориями: mkdir
, getcwd
, chmod
, stat
, remove
, rename
, rmdir
. Также chdir
, link
, walk
, listdir
, makedirs
, renames
, removedirs
, unlink
, symlink
. И еще кучу всякой всячины, не связанной с файловыми системами вовсе: fork
, getenv
, putenv
, environ
, getlogin
, system
,… Еще несколько дюжин вещей, о которых я упоминать здесь не буду.
Модуль os
предназначен для широкого круга задач; это такой ящик со всем, связанным с операционной системой. Есть много полезностей в os
, но в нём не всегда легко ориентироваться: часто необходимо слегка покопаться в модуле, прежде чем вы найдёте то, что нужно.
pathlib
переносит большинство функций по работе с файловой системой в объекты Path
.
Вот код, который создаёт директорию src/__pypackages__
и переименовывает наш файл .editorconfig
в src/.editorconfig
:
import os
import os.path
os.makedirs(os.path.join('src', '__pypackages__'), exist_ok=True)
os.rename('.editorconfig', os.path.join('src', '.editorconfig'))
Вот аналогичный код, использующий Path
from pathlib import Path
Path('src/__pypackages__').mkdir(parents=True, exist_ok=True)
Path('.editorconfig').rename('src/.editorconfig')
Заметьте, что второй пример кода гораздо проще читать, потому что он организован слева направо — это всё благодаря цепочкам методов.
Не забывайте про glob
Не только os
и os.path
содержат методы, связанные с файловой системой. Также стоит упомянуть про glob
, который нельзя назвать бесполезным.
Мы можем использовать функцию glob.glob
для поиска файлов по определённому шаблону:
from glob import glob
top_level_csv_files = glob('*.csv')
all_csv_files = glob('**/*.csv', recursive=True)
Модуль pathlib
также предоставляет аналогичные методы:
from pathlib import Path
top_level_csv_files = Path.cwd().glob('*.csv')
all_csv_files = Path.cwd().rglob('*.csv')
После перехода на модуль pathlib
, необходимость в модуле glob
пропадает полностью: всё необходимое уже является составной частью объектов Path
pathlib
делает простые вещи еще проще
pathlib
упрощает многие сложные ситуации, но помимо этого делает некоторые простые фрагменты кода еще проще.
Хотите прочитать весь текст в одном или нескольких файлах?
Можете открыть файл, прочитать содержимое, и закрыть файл, используя блок with
:
from glob import glob
file_contents = []
for filename in glob('**/*.py', recursive=True):
with open(filename) as python_file:
file_contents.append(python_file.read())
Или вы можете использовать метод read_text
на объектах Path
и генерацию списков чтобы получить аналогичный результат за одно выражение:
from pathlib import Path
file_contents = [
path.read_text()
for path in Path.cwd().rglob('*.py')
]
А что, если нужно записать в файл?
Вот как это выглядит, используя open
:
with open('.editorconfig') as config:
config.write('# config goes here')
Или же вы можете использовать метод write_text
:
Path('.editorconfig').write_text('# config goes here')
Если по каким-либо причинам вам необходимо использовать open
, либо в качестве контекстного менеджера, либо по личным предпочтениям, Path
предоставляет метод open
, как альтернативу:
from pathlib import Path
path = Path('.editorconfig')
with path.open(mode='wt') as config:
config.write('# config goes here')
Или же, начиная с Python 3.6, можно передать ваш Path
напрямую в open
:
from pathlib import Path
path = Path('.editorconfig')
with open(path, mode='wt') as config:
config.write('# config goes here')
Объекты Path делают ваш код очевиднее
На что указывают следующие переменные? Какой смысл у их значений?
person = '{"name": "Trey Hunner", "location": "San Diego"}'
pycon_2019 = "2019-05-01"
home_directory = '/home/trey'
Каждая переменная указывает на строку. Но каждая из них имеет разные значения: первая — это JSON, вторая — дата, и третья — это файловый путь.
Вот такое представление объектов слегка полезнее:
from datetime import date
from pathlib import Path
person = {"name": "Trey Hunner", "location": "San Diego"}
pycon_2019 = date(2019, 5, 1)
home_directory = Path('/home/trey')
Объекты JSON можно десериализовать в словарь, даты можно нативно представить, используя datetime.date
, а объекты файловых путей можно представить в виде Path
Использование объектов Path
делает ваш код более явным. Если вы хотите работать с датами, вы используете date
. Если хотите работать с файловыми путями, используйте Path
.
Я не особо большой сторонник ООП. Классы добавляют дополнительный слой абстракции, а абстракциям иногда свойственно усложнять систему, а не упрощать. При этом, я считаю, что pathlib.Path
— это полезная абстракция. Довольно быстро она становится общепринятым решением.
Благодаря PEP 519, Path
становятся стандартными для работы с путями. На момент Python 3.6, большинство методов os
, shutil
, os.path
корректно работают с этими объектами. Вы можете уже сегодня перейти на использование pathlib
, прозрачно для вашей кодовой базы!
Чего не хватает в pathlib
?
Хотя pathlib
и классная, но не всеобъемлющаяя. Определённо есть несколько возможностей, которые я бы хотел, чтобы были включены в модуль.
Первое, что приходит на ум, это недостаток методов у Path
, эквивалентных shutil
. И хотя вы можете передавать Path
как параметры shutil
для копирования/удаления/перемещения файлов и директорий, вызывать их как методы у объектов Path
не получится.
Так что, для копирования файлов, необходимо сделать что-то вроде этого:
from pathlib import Path
from shutil import copyfile
source = Path('old_file.txt')
destination = Path('new_file.txt')
copyfile(source, destination)
Также нет аналога метода os.chdir
. Это означает, что вам необходимо её импортировать, если возникнет необходимость сменить текущую директорию:
from pathlib import Path
from os import chdir
parent = Path('..')
chdir(parent)
Также нет эквивалента функции os.walk
. Хотя вы можете написать свою собственную функцию в духе walk
без особых сложностей.
Я надеюсь что однажды объекты pathlib.Path
будут содержать методы для некоторых из упомянутых операций. Но даже при таком раскладе я считаю гораздо более простым подход "использовать pathlib
с чем-то еще" чем "использовать os.path
и всё остальное".
Всегда ли нужно использовать pathlib
?
Начиная с Python 3.6, Path работают практически везде, где вы используете строки. Так что я не вижу причин не использовать pathlib
, если вы используете Python 3.6 и выше.
Если же вы используете более раннюю версию Python 3, вы в любой момент можете обернуть объект Path
в вызов str
чтобы получить строку, если возникла необходимость вернуться в страну строчек. Это не слишком изящно, но работает:
from os import chdir
from pathlib import Path
chdir(Path('/home/trey')) # Работает в Python 3.6+
chdir(str(Path('/home/trey'))) # Работает в более старых версиях
Часть 2. Ответы на вопросы.
После публикации первой части у некоторых людей возникли некоторые вопросы. Кто-то говорил, что я сравнивал библиотеки os.path
и pathlib
нечестно. Некоторые говорили, что использование os.path
настолько укоренилось в сообществе Python, что переход на новую библиотеку займёт очень большой промежуток времени. Еще я видел некоторые вопросы по поводу производительности.
В этой части я бы хотел прокомментировать эти вопросы. Можно считать это одновременно защитой pathlib
и чем-то вроде любовного письма к PEP 519.
Сравнение os.path
и pathlib
по-честному
В прошлой части я сравнивал следующие два фрагмента кода:
import os
import os.path
os.makedirs(os.path.join('src', '__pypackages__'), exist_ok=True)
os.rename('.editorconfig', os.path.join('src', '.editorconfig'))
from pathlib import Path
Path('src/__pypackages__').mkdir(parents=True, exist_ok=True)
Path('.editorconfig').rename('src/.editorconfig')
Это может показаться нечестным сравнением, потому что использование os.path.join
в первом примере гарантирует использование корректных разделителей на всех платформах, чего я не делал во втором примере. На самом же деле, всё в порядке, потому что Path автоматически нормализует разделители путей
Мы можем доказать это, посмотрев на преобразование объекта Path
в строку на Windows:
>>> str(Path('src/__pypackages__'))
'src\\__pypackages__'
Без разницы — используем ли мы метод joinpath
, '/'
в строке пути, оператор /
(еще одна приятная фишка Path
), или передаём отдельные аргументы в конструктор Path, мы получаем одинаковый результат:
>>> Path('src', '.editorconfig')
WindowsPath('src/.editorconfig')
>>> Path('src') / '.editorconfig'
WindowsPath('src/.editorconfig')
>>> Path('src').joinpath('.editorconfig')
WindowsPath('src/.editorconfig')
>>> Path('src/.editorconfig')
WindowsPath('src/.editorconfig')
Последний пример вызвал некоторое замешательство от людей, которые предполагали, что pathlib
недостаточно умён для замены /
на \
в строке пути. К счастью, всё в порядке!
С объектами Path
, вам больше не нужно беспокоиться по поводу направления слэшей: определяйте все свои пути с использованием /
, и результат будет предсказуем для любой платформы.
Вы не должны беспокоиться о нормализации путей
Если вы работаете на Linux или Mac, очень легко случайно добавить в код баги, которые затронут только пользователей Windows. Если не следить внимательно за использованием os.path.join
и\или os.path.normcase
для конвертации слэшей в подходящие для текущей платформы, вы можете написать код, который не будет корректно работать в Windows.
Вот пример Windows-specific бага:
import sys
import os.path
directory = '.' if not sys.argv[1:] else sys.argv[1]
new_file = os.path.join(directory, 'new_package/__init__.py')
При этом такой код будет работать корректно везде:
import sys
from pathlib import Path
directory = '.' if not sys.argv[1:] else sys.argv[1]
new_file = Path(directory, 'new_package/__init__.py')
Ранее программист был ответственен за конкатенацию и нормализацию путей, точно так же, как в Python 2 программист был ответственен за решение, где стоит использовать unicode вместо bytes. Больше это не ваша задача — все подобные проблемы Path
решает за вас.
Я не использую Windows, и у меня нет компьютера с Windows. Но огромное множество людей, которые будут использовать мой код, очень вероятно будут использовать Windows, и я хочу, чтобы у них всё работало корректно.
Если есть вероятность, что ваш код будет запускаться на Windows, вам стоит серьёзно задуматься над переходом на pathlib
.
Не беспокойтесь о нормализации: используйте Path
в любом случае, когда речь заходит о путях к файлам.
Звучит классно, но у меня сторонняя библиотека, которая не использует pathlib
!
У вас большая кодовая база, которая работает со строками в качестве путей. Зачем переходить на pathlib
, если это означает, что всё нужно переписывать?
Давайте представим, что у вас есть следующая функция:
import os
import os.path
def make_editorconfig(dir_path):
"""Create .editorconfig file in given directory and return filename."""
filename = os.path.join(dir_path, '.editorconfig')
if not os.path.exists(filename):
os.makedirs(dir_path, exist_ok=True)
open(filename, mode='wt').write('')
return filename
Функция принимает директорию, и создаёт там файл .editorconfig
, примерно так:
>>> import os.path
>>> make_editorconfig(os.path.join('src', 'my_package'))
'src/my_package/.editorconfig'
Если заменить строки на Path
, всё тоже заработает:
>>> from pathlib import Path
>>> make_editorconfig(Path('src/my_package'))
'src/my_package/.editorconfig'
Но… как?
os.path.join
принимает объекты Path
(начиная с Python 3.6). То же самое можно сказать и про os.makedirs
.
На самом деле, встроенная функция open
принимает Path
, shutil
принимает Path
и всё, что в стандартной библиотеке раньше принимало строку, теперь должно работать как с Path
, так и со строками.
За это стоит благодарить PEP 519, который предоставил абстрактный класс os.PathLike
и объявил, что все встроенные утилиты для работы с путями к файлам теперь должны работать как со строками, так и с Path
.
Но в моей любимой библиотеке есть Path, лучше стандартного!
Возможно, вы уже используете стороннюю библиотеку, которая предоставляет свою реализацию Path
, которая отличается от стандартной. Возможно, она вам нравится больше.
Например, django-environ, path.py, plumbum, и visidata содержат свои собственные объекты Path
. Некоторые из этих библиотек старше pathlib
, и приняли решение наследоваться от str
, чтобы их можно было передать в функции, ожидающие строки в качестве путей. Благодаря PEP 519, интеграция сторонних библиотек в ваш код будет проще, и без необходимости для наследования от str
.
Давайте представим, что вы не хотите использовать pathlib
, потому что Path
— иммутабельные объекты, а вам ну прям очень хочется менять их состояние. Благодаря PEP 519 вы можете создать свою самую-лучшую-мутабельную версию Path
. Для этого достаточно реализовать метод __fspath__
Любая самостоятельно написанная реализация Path
теперь может нативно работать с встроенными функциями Python, которые ожидают файловые пути. Даже если вам не нравится pathlib
, сам факт её существования — это большой плюс для сторонних библиотек с собственными Path
Но ведь pathlib.Path
и str
не смешиваются, правда?
Вы возможно думаете: это всё, конечно, здорово, но разве этот подход с иногда-строка-а-иногда-path не добавит ли сложности в мой код?
Ответ на этот вопрос — да, в некоторой степени. Но у этой проблемы есть довольно простой обход.
PEP 519 добавил еще несколько вещей, помимо PathLike
: во-первых, это способ конвертировать любой PathLike
в строку, а во-вторых, это способ любой PathLike
превратить в Path
.
Возьмём два объекта — строку и Path
(или что угодно с методом fspath):
from pathlib import Path
import os.path
p1 = os.path.join('src', 'my_package')
p2 = Path('src/my_package')
Функция os.fspath
нормализирует оба объекта и превратит в строки:
>>> from os import fspath
>>> fspath(p1), fspath(p2)
('src/my_package', 'src/my_package')
При этом, Path
может принять оба эти объекта в конструктор и преобразовать их в Path
:
>>> Path(p1), Path(p2)
(PosixPath('src/my_package'), PosixPath('src/my_package'))
Это означает, что вы можете преобразовать результат make_editorconfig
назад в Path
при необходимости:
>>> from pathlib import Path
>>> Path(make_editorconfig(Path('src/my_package')))
PosixPath('src/my_package/.editorconfig')
Хотя, конечно, лучшим решением было бы переписать make_editorconfig
, используя pathlib
.
pathlib
слишком медленный
Я видел несколько раз вопросы по поводу производительности pathlib
. Это правда — pathlib
может быть медленным. Создание тысяч объектов Path
может заметно сказаться на поведении программы.
Я решил замерить производительность pathlib
и os.path
на своём компьютере, используя две разные программы, которые ищут все .py
файлы в текущей директории
Вот версия os.walk
:
from os import getcwd, walk
extension = '.py'
count = 0
for root, directories, filenames in walk(getcwd()):
for filename in filenames:
if filename.endswith(extension):
count += 1
print(f"{count} Python files found")
А вот версия с Path.rglob
:
from pathlib import Path
extension = '.py'
count = 0
for filename in Path.cwd().rglob(f'*{extension}'):
count += 1
print(f"{count} Python files found")
Тестирование производительности программ, которые работают с файловой системой — задача хитрая, потому что время работы может меняться довольно сильно. Я решил запустить каждый скрипт 10 раз и сравнил лучшие результаты для каждой программы.
Обе программы нашли 97507 файла в директории, в которой я их запускал. Первый сработал за 1.914 секунды, второй закончил работу за 3.430 секунды.
Когда я установил параметр extension=''
, эти программы находят примерно 600,000 файлов, и разница увеличивается. Первая программа сработала за 1.888 секунд, а вторая за 7.485 секунд.
Так что, pathlib
работает примерно вдвое медленнее для файлов с расширением .py
, и в четыре раза медленнее при запуске на моей домашней директории. Относительный разрыв в производительности pathlib
и os
весьма велик.
В моём случае, эта скорость мало что меняет. Я искал все файлы в своей директории и потерял 6 секунд. Если бы у меня была задача обработать 10 миллионов файлов, я бы скорее всего её переписал. Но пока такой необходимости нет, можно и подождать.
Если у вас есть горячий фрагмент кода, и pathlib
явно негативно влияет на его работу, нет ничего плохого в том, чтобы заменить его на альтернативу. Не стоит оптимизировать код, который не является узким местом — это лишняя трата времени, которая к тому же обычно приводит к плохо читаемому коду, без особого выхлопа.
Улучшение читаемости
Я хотел бы закончить этот поток мыслей некоторыми примерами рефакторинга при помощи pathlib
. Я взял пару небольших примеров кода, который работает с файлами и заставил их работать с pathlib
. Оставлю большую часть кода без комментариев на ваш суд — решайте, какая версия вам нравится больше.
Вот функция make_editorconfig
, которую мы видели ранее:
import os
import os.path
def make_editorconfig(dir_path):
"""Create .editorconfig file in given directory and return filename."""
filename = os.path.join(dir_path, '.editorconfig')
if not os.path.exists(filename):
os.makedirs(dir_path, exist_ok=True)
open(filename, mode='wt').write('')
return filename
А вот версия, переписанная на pathlib
:
from pathlib import Path
def make_editorconfig(dir_path):
"""Create .editorconfig file in given directory and return filepath."""
path = Path(dir_path, '.editorconfig')
if not path.exists():
path.parent.mkdir(exist_ok=True, parent=True)
path.touch()
return path
Вот консольная программа которая принимает строку с директорией и печатает содержимое файла .gitignore
, если он существует:
import os.path
import sys
directory = sys.argv[1]
ignore_filename = os.path.join(directory, '.gitignore')
if os.path.isfile(ignore_filename):
with open(ignore_filename, mode='rt') as ignore_file:
print(ignore_file.read(), end='')
То же самое, но с pathlib
:
from pathlib import Path
import sys
directory = Path(sys.argv[1])
ignore_path = directory / '.gitignore'
if ignore_path.is_file():
print(ignore_path.read_text(), end='')
Вот программа, которая печатает все дублирующиеся файлы в текущей папке и подпапках:
from collections import defaultdict
from hashlib import md5
from os import getcwd, walk
import os.path
def find_files(filepath):
for root, directories, filenames in walk(filepath):
for filename in filenames:
yield os.path.join(root, filename)
file_hashes = defaultdict(list)
for path in find_files(getcwd()):
with open(path, mode='rb') as my_file:
file_hash = md5(my_file.read()).hexdigest()
file_hashes[file_hash].append(path)
for paths in file_hashes.values():
if len(paths) > 1:
print("Duplicate files found:")
print(*paths, sep='\n')
То же самое, но c pathlib
:
from collections import defaultdict
from hashlib import md5
from pathlib import Path
def find_files(filepath):
for path in Path(filepath).rglob('*'):
if path.is_file():
yield path
file_hashes = defaultdict(list)
for path in find_files(Path.cwd()):
file_hash = md5(path.read_bytes()).hexdigest()
file_hashes[file_hash].append(path)
for paths in file_hashes.values():
if len(paths) > 1:
print("Duplicate files found:")
print(*paths, sep='\n')
Изменения незначительные, но, по-моему, в сумме дают положительный результат. Я лично предпочитаю варианты с использованием pathlib
.
Начните использовать объекты pathlib.Path
Давайте повторим.
Разделители /
в строках pathlib.Path
автоматически конвертируются в правильный разделитель для текущей операционной системы. Это важная особенность, которая делает код более читаемым и избавляет от потенциальных багов.
>>> path1 = Path('dir', 'file')
>>> path2 = Path('dir') / 'file'
>>> path3 = Path('dir/file')
>>> path3
WindowsPath('dir/file')
>>> path1 == path2 == path3
True
Встроенные в Python функции (напр. open
) также принимают Path
, что значит, что вы можете использовать pathlib
, даже если ваши сторонние библиотеки этого не делают!
from shutil import move
def rename_and_redirect(old_filename, new_filename):
move(old, new)
with open(old, mode='wt') as f:
f.write(f'This file has moved to {new}')
>>> from pathlib import Path
>>> old, new = Path('old.txt'), Path('new.txt')
>>> rename_and_redirect(old, new)
>>> old.read_text()
'This file has moved to new.txt'
И если вам не нравится pathlib
, вы можете использовать стороннюю библиотеку, которая реализует интерфейс PathLike
. Это отлично, потому что даже если вам не нравится стандартная реализация, вы всё равно получите выгоду от изменений, принятых в PEP 519.
>>> from plumbum import Path
>>> my_path = Path('old.txt')
>>> with open(my_path) as f:
... print(f.read())
...
This file has moved to new.txt
И хотя pathlib
может быть медленнее альтернатив, обычно это не так важно (по крайней мере, мой опыт показывает так), к тому же вы всегда можете вернуться к использованию строк для фрагментов кода, чувствительных к производительности.
В целом, pathlib
позволяет писать более читаемый код. Вот короткий и ёмкий скрипт на Python для иллюстрации моей точки зрения:
from pathlib import Path
gitignore = Path('.gitignore')
if gitignore.is_file():
print(gitignore.read_text(), end='')
Модуль pathlib
— отличный. Начните же его использовать!