Хабрахабр

[Перевод] Почему вам следует использовать pathlib

Представляю вашему вниманию перевод статьи Why you should be using pathlib и её продолжения, No really, pathlib is great. От переводчика: Привет, хабр! При этом за радаром рискуют пройти не столь значительные (хотя, := назвать серьёзным нововведением язык не поворачивается), но весьма полезные нововведения в язык. Много внимания нынче уделяется таким новым возможностям Python, как asyncio, оператору :=, и опциональной типизации. В частности, на хабре статей, посвящённых сабжу, я не нашел (кроме одного абзаца тут), поэтому решил исправить ситуацию.

Я ошибался. Когда я открыл для себя тогда еще новый модуль pathlib несколько лет назад, я по простоте душевной решил, что это всего лишь слегка неуклюжая объектно-ориентированная версия модуля os.path. pathlib на самом деле чудесен!

Я надеюсь, что эта статья вдохновит вас использовать pathlib в любой ситуации, касающейся работы с файлами в Python. В этой статье я попытаюсь вас влюбить в pathlib.

Часть 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. Классический модуль os.path предназначен для работы с путями.

Также chdir, link, walk, listdir, makedirs, renames, removedirs, unlink, symlink. os содержит кучу утилит для работы с файлами и директориями: mkdir, getcwd, chmod, stat, remove, rename, rmdir. И еще кучу всякой всячины, не связанной с файловыми системами вовсе: 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

Также стоит упомянуть про glob, который нельзя назвать бесполезным. Не только os и os.path содержат методы, связанные с файловой системой.

Мы можем использовать функцию 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')

6, можно передать ваш Path напрямую в open: Или же, начиная с Python 3.

from pathlib import Path path = Path('.editorconfig')
with open(path, mode='wt') as config: config.write('# config goes here')

Объекты Path делают ваш код очевиднее

Какой смысл у их значений? На что указывают следующие переменные?

person = ''
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

Если вы хотите работать с датами, вы используете date. Использование объектов Path делает ваш код более явным. Если хотите работать с файловыми путями, используйте Path.

Классы добавляют дополнительный слой абстракции, а абстракциям иногда свойственно усложнять систему, а не упрощать. Я не особо большой сторонник ООП. Path — это полезная абстракция. При этом, я считаю, что pathlib. Довольно быстро она становится общепринятым решением.

На момент Python 3. Благодаря PEP 519, Path становятся стандартными для работы с путями. Вы можете уже сегодня перейти на использование pathlib, прозрачно для вашей кодовой базы! 6, большинство методов os, shutil, os.path корректно работают с этими объектами.

Чего не хватает в pathlib?

Определённо есть несколько возможностей, которые я бы хотел, что бы были включены в модуль. Хотя pathlib и классная, но не всеобъемлющаяя.

И хотя вы можете передавать Path как параметры shutil для копирования/удаления/перемещения файлов и директорий, вызывать их как методы у объектов Path не получится. Первое, что приходит на ум, это недостаток методов у Path, эквивалентных shutil.

Так что, для копирования файлов, необходимо сделать что-то вроде этого:

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)

Хотя вы можете написать свою собственную функцию в духе walk без особых сложностей. Также нет эквивалента функции os.walk.

Path будут содержать методы для некоторых из упомянутых операций. Я надеюсь что однажды объекты pathlib. Но даже при таком раскладе я считаю гораздо более простым подход "использовать pathlib с чем-то еще" чем "использовать os.path и всё остальное".

Всегда ли нужно использовать pathlib?

6, Path работают практически везде, где вы используете строки. Начиная с Python 3. 6 и выше. Так что я не вижу причин не использовать pathlib, если вы используете Python 3.

Это не слишком изящно, но работает: Если же вы используете более раннюю версию 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')

На самом же деле, всё в порядке, потому что Path автоматически нормализует разделители путей Это может показаться нечестным сравнением, потому что использование os.path.join в первом примере гарантирует использование корректных разделителей на всех платформах, чего я не делал во втором примере.

Мы можем доказать это, посмотрев на преобразование объекта 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, вам больше не нужно беспокоиться по поводу направления слэшей: определяйте все свои пути с использованием /, и результат будет предсказуем для любой платформы.

Вы не должны беспокоиться о нормализации путей

Если не следить внимательно за использованием os.path.join и\или os.path.normcase для конвертации слэшей в подходящие для текущей платформы, вы можете написать код, который не будет корректно работать в Windows. Если вы работаете на Linux или Mac, очень легко случайно добавить в код баги, которые затронут только пользователей 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')

Больше это не ваша задача — все подобные проблемы Path решает за вас. Ранее программист был ответственен за конкатенацию и нормализацию путей, точно так же, как в Python 2 программист был ответственен за решение, где стоит использовать unicode вместо bytes.

Но огромное множество людей, которые будут использовать мой код, очень вероятно будут использовать 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'

Но… как?

6). os.path.join принимает объекты Path (начиная с Python 3. То же самое можно сказать и про os.makedirs.
На самом деле, встроенная функция open принимает Path, shutil принимает Path и всё, что в стандартной библиотеке раньше принимало строку, теперь должно работать как с Path, так и со строками.

PathLike и объявил, что все встроенные утилиты для работы с путями к файлам теперь должны работать как со строками, так и с Path. За это стоит благодарить PEP 519, который предоставил абстрактный класс os.

Но в моей любимой библиотеке есть Path, лучше стандартного!

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

Некоторые из этих библиотек старше pathlib, и приняли решение наследоваться от str, что бы их можно было передать в функции, ожидающие строки в качестве путей. Например, django-environ, path.py, plumbum, и visidata содержат свои собственные объекты Path. Благодаря PEP 519, интеграция сторонних библиотек в ваш код будет проще, и без необходимости для наследования от str.

Благодаря PEP 519 вы можете создать свою самую-лучшую-мутабельную версию Path. Давайте представим, что вы не хотите использовать pathlib, потому что Path — иммутабельные объекты, а вам ну прям очень хочется менять их состояние. Для этого достаточно реализовать метод __fspath__

Даже если вам не pathlib, сам факт её существования — это большой плюс для сторонних библиотек с собственными Path Любая самостоятельно написанная реализация Path теперь может нативно работать с встроенными функциями Python, которые ожидают файловые пути.

Но ведь 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 раз и сравнил лучшие результаты для каждой программы. Тестирование производительности программ, которые работают с файловой системой — задача хитрая, потому что время работы может меняться довольно сильно.

Первый сработал за 1. Обе программы нашли 97507 файла в директории, в которой я их запускал. 430 секунды. 914 секунды, второй закончил работу за 3.

Первая программа сработала за 1. Когда я установил параметр extension='', эти программы находят примерно 600,000 файлов, и разница увеличивается. 485 секунд. 888 секунд, а вторая за 7.

Относительный разрыв в производительности pathlib и os весьма велик. Так что, pathlib работает примерно вдвое медленнее для файлов с расширением .py, и в четыре раза медленнее при запуске на моей домашней директории.

Я искал все файлы в своей директории и потерял 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

Давайте повторим.

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

>>> path1 = Path('dir', 'file')
>>> path2 = Path('dir') / 'file'
>>> path3 = Path('dir/file')
>>> path3
WindowsPath('dir/file')
>>> path1 == path2 == path3
True

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

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'

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

>>> 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 может быть медленнее альтернатив, обычно это не так важно (по крайней мере, мой опыт показывает так), к тому же вы всегда можете вернуться к использованию строк для фрагментов кода, чувствительных к производительности.

Вот короткий и ёмкий скрипт на Python для иллюстрации моей точки зрения: В целом, pathlib позволяет писать более читаемый код.

from pathlib import Path
gitignore = Path('.gitignore')
if gitignore.is_file(): print(gitignore.read_text(), end='')

Начните же его использовать! Модуль pathlib — отличный.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть