Лучшая практика: работа с путями и файлами в Python
Это не совсем обычный пост по «питону». Здесь мы не только решаем частую проблему при работе с путями и файлами в Python, объясняя, как это сделать максимально правильно. Здесь мы также попытаемся рассказать, как мыслит опытный программист, наглядно покажем, как постепенно он дорабатывает свой код. Увидев и поняв, как это работает, вы получите возможность значительно поднять свой профессиональный уровень. Не верите? Прочитайте и попробуйте!
Этот большой пост – вольный перевод вот этой оригинальной статьи (с нашими дополнениями в местах, где это показалось нужным), которую написал Майкл Аллгувер. В ней автор со знанием дела делится своим стилем программирования, рассказывая о правильном подходе к Python.
Проблема: перечисление папок и дисков
Недавно во время работы над проектом коллега спросил меня, можно ли в Python вывести список содержимого дисков. Конечно, можно. Более того, поскольку это совсем не сложно, я хотел бы рассмотреть этот случай подробно, чтобы проиллюстрировать лучшие практики, рекомендуемые для работы с путями к дискам.
Поверьте, это не голая теория — подобные вещи очень часто встречаются в реальной жизни. Давайте посмотрим, как эти проблемы решать правильно.
Шаг 1: Как ввести правильный путь?
Предполагая, что вы хотите точно получить листинг файлов от конкретного пути, начнем с выбора пользовательского каталога в системе Windows 10:
path_dir: str = "C:\Users\sselt\Documents\blog_demo"
Переменные, назначенные при выполнении, немедленно вызывают ошибку:
SyntaxError: (unicode error) 'unicodeescape' codec can't decode bytes in position 2-3: truncated \UXXXXXXXX escape
Интерпретатор не понимает последовательность символов \U
, так как она инициирует символы Unicode аналогичной последовательности. Эта распространенная проблема, которую постоянно гуглят тысячи начинающих питонистов, возникает потому, что в системе Windows в качестве разделителя путей используется обратная косая черта «\» (бэкслеш), а в Linux — обычная косая черта «/» (слеш).
К сожалению, поскольку разделитель Windows также является инициатором для различных специальных символов или escape-последовательностью в Unicode, это явно все запутывает и сбивает с толку. Один и тот же код работает на одних системах и творит чудеса на вторых, хотя внешне все выглядит одинаково.
Так же, как мы не ожидаем в ближайшее время согласованности в использовании десятичных разделителей в разных странах, наш единственный выбор — это одно из трех решений.
Решение 1 — отвратительный вариант
Просто избегайте разделителя Windows и вместо этого пишите путь, используя только разделители Linux:
path_dir: str = "C:/Users/sselt/Documents/blog_demo"
После этого интерпретатор распознает правильный путь, считая, что это Linux-система. Как минимум на серверных системах это должно работать как надо… Но универсальностью здесь и не пахнет.
Решение 2 — еще более отвратительный вариант
Давайте не будем останавливаться, и покажем, до чего порой додумываются некоторые новички, решая эту проблему.
Используйте экранирующие последовательности — в интернете полно подобных советов.
path_dir: str = "C:\\Users\sselt\Documents\\blog_demo"
Помимо неразборчивости такого кода, меня беспокоит то, что не нужно использовать экранирующие последовательности в каждой комбинации символов-разделителей, а только перед «U
» и «b
».
Слабо держать все это под контролем (особенно если ваша программа размером на несколько десяток тысяч строк кода)?
Решение 3 — элегантное
Используйте необработанные строки с «r
» в качестве префикса, чтобы указать, что специальные символы не должны оцениваться.
path_dir: str = r"C:\Users\sselt\Documents\blog_demo"
Как тебе такое решение, Маск? Все просто и универсально. Попробуйте найти в интернете аналог моего решения — это будет сложно, поверьте.
Шаг 2: Сканирование файлов
Вернемся к нашей типичной задаче — перечислить все элементы в папке. Мы уже знаем путь, а также то, как его правильно записать без косяков.
Простая команда os.listdir
перечисляет все строки, то есть только имена файлов по пути. Здесь и во всех других примерах я использую подсказки типов для дополнительного документирования кода, что сразу приучает нас писать качественно. Этот синтаксис стал доступен, начиная с Python 3.5.
import os from typing import List path_dir: str = r"C:\Users\sselt\Documents\blog_demo" content_dir: List[str] = os.listdir(path_dir)
Отображение файлов в порядке, но меня больше интересует статистика файлов, для которой у нас есть os.stat
.
Шаг 3: Конкатенация путей
Продираемся дальше в решение вроде бы простой и частой задачи.
Чтобы передать путь к файлу, мы должны сначала объединить имя файла и путь. Я часто встречал следующие конструкции в гугле и даже использовал их, когда был молодой. Например, распространены такие варианты:
path_file: str = path_dir + "/" + filename path_file: str = path_dir + "\\" + filename path_file: str = "{}/{}".format(path_dir, filename) path_file: str = f"{path_dir}/{filename}"
Первые два варианта сверху — отвратительны, потому что они конкатенируют строки со знаком «+
», который в новом Python не нужен.
Особенно все это отвратительно, потому что в Windows нужен двойной разделитель, иначе он будет оценен как управляющая последовательность для закрывающей кавычки. Что создает непредсказуемый «оопс» в процессе эксплуатации такого кода.
Нижние два варианта несколько лучше, поскольку они используют форматирование строк, но они все равно не решают проблему системной зависимости. Если я применю результат под Windows, то получу функциональный, но непоследовательный путь со смесью разделителей.
Смотрите сами:
filename = "some_file" print("{}/{}".format(path_dir, filename)) ...: 'C:\\Users\\sselt\\Documents\\blog_demo/some_file'
Правильное решение, не зависящее от ОС
Решение из Python — os.sep
или os.path.sep
. Оба возвращают разделитель путей соответствующей системы. Функционально они идентичны, но второй, более явный синтаксис сразу показывает задействованный разделитель.
Это означает, что можно написать так:
path_file = "{}{}{}".format(path_dir, os.sep, filename)
Результат будет лучше, но за счет сложного кода, если вы хотите объединить несколько сегментов пути. Поэтому принято объединять элементы пути через конкатенацию строк. Это еще короче и универсальнее:
path_file = os.sep.join([path_dir, filename])
В качестве домашнего задания погуглите триллионов решений в гугле для этой задачи и сравните с нашим подходом — теперь вы сразу увидите косяки во множестве чужих решений.
Первый полный запуск
Перейдем к каталогу:
for filename in os.listdir(path_dir): path_file = os.sep.join([path_dir, filename]) print(os.stat(path_file))
Один из результатов — st_atime
— время последнего обращения к файлу, st_mtime
— время последней модификации и st_ctime
— время создания. Кроме того, st_size
дает размер файла в байтах. В данный момент мне нужны только размер и дата последней модификации, поэтому я решил сохранить простой формат списка.
import os from typing import List, Tuple filesurvey: List[Tuple] = [] content_dir: List[str] = os.listdir(path_dir) for filename in content_dir: path_file = os.sep.join([path_dir, filename]) stats = os.stat(path_file) filesurvey.append((path_dir, filename, stats.st_mtime, stats.st_size))
Конечная функция с рекурсией
Полученный результат поначалу кажется удовлетворительным, большинство на этом бы и остановилось. Но почесав как следует репу, мы осознаем, что возникают две новые проблемы. Listdir
не делает различий между файлами и папками, обращается только к уровню папок и не обрабатывает вложенные папки.
Следовательно, нам нужна рекурсивная функция, которая различает файлы и папки и спускается вниз для сканирования низких уровней. os.path.isdir
как раз проверяет, есть ли папка ниже пути.
def collect_fileinfos(path_directory: str, filesurvey: List[Tuple]): content_dir: List[str] = os.listdir(path_directory) for filename in content_dir: path_file = os.sep.join([path_directory, filename]) if os.path.isdir(path_file): collect_fileinfos(path_file, filesurvey) else: stats = os.stat(path_file) filesurvey.append((path_directory, filename, stats.st_mtime, stats.st_size)) filesurvey: List[Tuple] = [] collect_fileinfos(path_dir, filesurvey)
Сделать результаты более полезными
Готово! Мы решили проблему менее чем за 10 строк. Проблема только в том, что если мы запустим такой скрипт на большой файловой системе, то мы получим большой (нет, ОГРОМНЫЙ) монотонный вывод из строчек. Как все это понять и переварить в реальном мире? Я сразу обещал, что мы решаем не академические задачки, а разбираем решения из реального мира.
Поскольку я планировал иметь filesurvey
как список кортежей, я могу легко перенести результат во фрейм данных Pandas и проанализировать его там для анализа итогов поиска, сохраненных в папках, и т.д. Pandas позволит найти и вычленить любые закономерности, которые бы вы хотели узнать, с минимальными усилиями.
import pandas as pd df: pd.DataFrame = pd.DataFrame(filesurvey, columns=('path_directory', 'filename', 'st_mtime', 'st_size'))
… но, к сожалению, и это не самая лучшая практика.
Я знаю, этот пост сразу обещал решать проблемы с помощью только лучших практик. Поэтому придется снова все переписать.
Далее я собираюсь снова обратиться к этому сценарию и решить его по-настоящему элегантно.
Одна и та же проблема: перечисление папок и дисков
Выше мы использовали рекурсивную функцию для решения, состоящего менее чем из 10 строк, для сканирования папок и обеспечения возможности оценки файлов по дате модификации и размеру. Теперь я собираюсь несколько поднять планку для этого примера, показав лучшие альтернативы.
Конкатенация пути с помощью Pathlib
Старое вино в новых бутылках? Решение предыдущего примера с помощью склеивания путей было следующим:
path_file = os.sep.join([path_dir, filename])
Преимущество этого способа в том, что решение не зависит от операционной системы, и не нужно объединять строки знаком «+» или как-то париться с их форматированием.
Однако это чревато ошибками, поскольку можно случайно или по ошибке определить путь к каталогу с закрывающим разделителем путей.
path_dir: str = r"C:/Users/sselt/Documents/blog_demo/" # abschließender Trenner filename: str = "some_file" path_file = os.sep.join([path_dir, filename]) # C:/Users/sselt/Documents/blog_demo/\some_file
Хотя в этом примере показан работающий код, тренированный взгляд бывалого кодера сразу видит, что неправильный разделитель приводит к ошибке при вызове пути. Такие ошибки могут возникать, когда пользователи управляют путями в конфигурационных файлах, вдали от кода, не обращая внимания на общее соглашение.
Начиная с Python 3.4, появилось лучшее решение — модуль pathlib
. Он обрабатывает функции файлов и папок модуля os в Python с помощью объектно-ориентированного подхода.
Напомню старый вариант:
import os path = "C:/Users/sselt/Documents/blog_demo/" os.path.isdir(path) os.path.isfile(path) os.path.getsize(path)
А вот новая альтернатива:
from pathlib import Path path: Path = Path("C:/Users/sselt/Documents/blog_demo/") path.is_dir() path.is_file() path.stat().st_size
Оба варианта дают абсолютно одинаковый результат. Так почему же второй вариант намного лучше? Давайте пораскинем мозгами.
Объектно-ориентированный и более устойчивый к ошибкам
Вызовы в основном объектно-ориентированы, и это может быть или не быть вашим предпочтением — но мне так нравится гораздо больше. Здесь у нас есть объект, например, определение пути, у которого есть атрибуты и методы. Это правильный подход для энтерпрайз-программирования.
Однако пример, примененный здесь для перегрузки операторов, более интересен:
filename: Path = Path("some_file.txt") path: Path = Path("C:/Users/sselt/Documents/blog_demo") print( path / filename ) # C:\Users\sselt\Documents\blog_demo\some_file.txt
Сначала разделение на два пути кажется недопустимым. Однако объект path был просто перегружен таким образом, что функционирует как объединяющий путь.
В дополнение к этому синтаксическому сахару, объекты path
будут перехватывать и другие типичные ошибки:
filename: Path = Path("some_file.txt") path: Path = Path("C:/Users/sselt/Documents/blog_demo/") path: Path = Path("C:/Users/sselt/Documents/blog_demo//") path: Path = Path("C:\\Users/sselt\\Documents/blog_demo") print(path/filename) # C:\Users\sselt\Documents\blog_demo\some_file.txt
Этот вариант не только красивее, но и устойчивее к ложным вводам. В дополнение к другим преимуществам код также не зависит от операционной системы. Определяется только общий объект пути, который в системе Windows выглядит как WindowsPath, а в системе Linux — как PosixPath
.
Большинство функций, которые обычно ожидают строку в качестве пути, могут работать непосредственно с путем. В редких случаях вам может понадобиться разрешить объект просто с помощью str(Path)
.
Обработка пути с помощью os.walk
В моем последнем решении я использовал os.listdir, os.path.isdir
и рекурсивную функцию для итерации по дереву путей и различения папок и файлов.
Но os.walk
предлагает лучшее решение. Этот метод создает не список, а итератор, который можно вызывать построчно. Результат содержит соответствующий путь к папке и список всех файлов данных в пределах этого пути. Все это происходит рекурсивно, так что вы получаете все файлы одним вызовом. Не правда ли круто?
Лучшее решение с os.walk и Pathlib
Если вы объедините две вышеупомянутые техники, вы получите более простое решение, полностью независимое от ОС, более устойчивое к непоследовательным форматам путей и свободное от явных рекурсий:
filesurvey = [] for row in os.walk(path): # row beinhaltet jeweils einen Ordnerinhalt for filename in row[2]: # row[2] ist ein tupel aus Dateinamen full_path: Path = Path(row[0]) / Path(filename) # row[0] ist der Ordnerpfad filesurvey.append([path, filename, full_path.stat().st_mtime, full_path.stat().st_size])
Подведение итогов: методологическая задача поста
Я предлагаю остановиться. Хотя приведенное решение типичной задачи вполне рабочее и достаточно устойчиыо к превратностям судьбы, его можно продолжать совершенствовать и дальше. Я бы посоветовал программистам находить некий разумный баланс между перфекционизмом и разумной стабильностью, на которой пора остановиться.
Если вы можете дополнить этот пример лучшей практикой, не стесняйтесь, напишите коммент под этим постом.
И в заключение самое главное — этот пост не про сканирование файлов и папок, хотя именно этим мы и занимались. Помимо решения задачи, я попытался показать, КАК ДУМАЕТ программист, когда развивает свое решение, которое, как правило, улучшает последовательными итерациями, постепенно достигая некоего приемлемого уровня стабильности.
Это приходит с опытом, но теперь увидев как это работает, вы посмотрите на чужой код из интернета совсем другими глазами — глазами человека, который учится предугадывать дефекты и видеть неявные слабые места. А не просто бездумно копипастит чужие примеры, «потому что они ведь работают». Возможно, такой подход усложнит вам жизнь и замедлит в плане работы, но зато именно он и даст толчок к вашему профессиональному росту как программиста.
Сообщить об опечатке
Текст, который будет отправлен нашим редакторам: