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

в 21:41, , рубрики: pathlib, python, Программирование

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

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

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

Часть 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 — отличный. Начните же его использовать!

Автор: Dasdy

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js