
Сортировка файлов на Python: от плана до безопасного перемещения
Собираем консольный сортировщик: классифицируем файлы по расширению, сначала показываем план, запрещаем перезапись и сохраняем журнал для отката.
Содержание
Папка загрузок быстро смешивает документы, изображения и архивы. Простая автоматизация может разложить их по каталогам, но ошибка в пути или совпадение имени способно испортить данные. Поэтому хороший сортировщик сначала строит полный план, показывает его человеку и только после явного подтверждения перемещает файлы.
В итоговом инструменте используются pathlib, аргументы командной строки и журналирование. Работайте только с копией учебной папки, пока не проверите все ветки.
Определите категории без изменения файлов
Создайте файл sorter.py. Первая функция принимает путь и возвращает имя категории по расширению:
from pathlib import Path
CATEGORIES = {
".jpg": "images",
".jpeg": "images",
".png": "images",
".webp": "images",
".pdf": "documents",
".docx": "documents",
".xlsx": "documents",
".zip": "archives",
".tar": "archives",
".gz": "archives",
}
def category_for(path):
return CATEGORIES.get(path.suffix.casefold(), "other")
suffix возвращает последнее расширение. Для backup.tar.gz результатом будет .gz, что в этой таблице всё равно ведёт в archives. casefold() приводит .JPG и .jpg к одному правилу. Функция ничего не читает и не перемещает, поэтому её легко проверить тестами.
Постройте и напечатайте план
Добавьте функцию, которая смотрит только обычные файлы непосредственно в исходном каталоге. Вложенные папки она не обходит:
def build_plan(source_dir, destination_dir):
plan = []
for source in sorted(source_dir.iterdir()):
if not source.is_file():
continue
category = category_for(source)
destination = destination_dir / category / source.name
plan.append((source, destination))
return plan
def print_plan(plan):
if not plan:
print("Подходящих файлов нет")
return
for source, destination in plan:
print(f"{source} -> {destination}")
Создайте рядом учебные каталоги sample/inbox и sample/sorted, положите в inbox несколько копий неважных файлов. Пока код только вычисляет пути. Он не создаёт конечные каталоги и не меняет исходные данные.
Добавьте интерфейс с безопасным значением по умолчанию
Импортируйте argparse и добавьте точку входа. Без флага --apply она только показывает план:
import argparse
def build_parser():
parser = argparse.ArgumentParser(
description="Сортирует файлы верхнего уровня по расширениям"
)
parser.add_argument("source", type=Path, help="каталог с файлами")
parser.add_argument("destination", type=Path, help="каталог категорий")
parser.add_argument(
"--apply",
action="store_true",
help="выполнить показанные перемещения",
)
return parser
def main(argv=None):
args = build_parser().parse_args(argv)
source_dir = args.source.resolve()
destination_dir = args.destination.resolve()
if not source_dir.is_dir():
raise SystemExit(f"Исходный каталог не найден: {source_dir}")
if source_dir == destination_dir:
raise SystemExit("Исходный и конечный каталоги совпадают")
plan = build_plan(source_dir, destination_dir)
print_plan(plan)
if not args.apply:
print("Предварительный просмотр: файлы не изменены")
return 0
return apply_plan(plan, destination_dir)
if __name__ == "__main__":
raise SystemExit(main())
Функция apply_plan будет добавлена в следующем разделе. До этого запускайте только без --apply: python sorter.py sample/inbox sample/sorted. Проверьте каждую пару путей. В вывод не должны попадать вложенные каталоги и сам sorter.py, если он лежит вне inbox.
Запретите перезапись до первого перемещения
Известный конфликт нужно обнаружить, пока все исходники ещё на месте. Добавьте функцию проверки:
def validate_plan(plan):
destinations = set()
for source, destination in plan:
key = str(destination).casefold()
if key in destinations:
raise ValueError(f"Два файла претендуют на путь: {destination}")
destinations.add(key)
if destination.exists():
raise FileExistsError(f"Целевой файл уже существует: {destination}")
if not source.is_file():
raise FileNotFoundError(f"Исходный файл исчез: {source}")
Сравнение casefold помогает заранее увидеть коллизию имён на файловой системе без учёта регистра. Это не заменяет проверку самой файловой системы, но предотвращает очевидную ошибку внутри одного плана.
Между проверкой и перемещением другой процесс всё ещё может создать целевой файл. Для общей папки остановите параллельные загрузки или используйте более строгую систему координации. Учебный скрипт рассчитан на каталог, с которым в этот момент работает один пользователь.
Сохраните журнал и выполните план
Добавьте импорты json и shutil, затем функцию применения. Она сначала записывает весь план в move-manifest.json, после чего создаёт каталоги и перемещает файлы:
import json
import shutil
def apply_plan(plan, destination_dir):
validate_plan(plan)
destination_dir.mkdir(parents=True, exist_ok=True)
manifest_path = destination_dir / "move-manifest.json"
if manifest_path.exists():
raise FileExistsError(f"Журнал уже существует: {manifest_path}")
records = [
{"source": str(source), "destination": str(destination)}
for source, destination in plan
]
manifest_path.write_text(
json.dumps(records, ensure_ascii=False, indent=2),
encoding="utf-8",
)
for source, destination in plan:
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.move(source, destination)
print(f"Перемещено файлов: {len(plan)}")
print(f"Журнал: {manifest_path}")
return 0
Снова выполните предварительный просмотр. Только после проверки копии данных запустите python sorter.py sample/inbox sample/sorted --apply. Ожидается число перемещённых файлов и путь журнала. Повторный запуск остановится из-за существующего move-manifest.json, а не затрёт историю предыдущего действия.
shutil.move внутри одного диска обычно переименовывает путь, а между файловыми системами может копировать данные и удалять источник. Поэтому свободное место и права нужны с обеих сторон. Сбой посередине оставит часть файлов перемещённой; журнал помогает понять, какие пары были запланированы.
Выполните осторожный откат по журналу
Создайте отдельный файл undo_sort.py. Он проходит записи в обратном порядке и возвращает только те файлы, для которых конечный путь существует, а исходный свободен:
import json
import shutil
import sys
from pathlib import Path
manifest_path = Path(sys.argv[1]).resolve()
records = json.loads(manifest_path.read_text(encoding="utf-8"))
for record in reversed(records):
source = Path(record["source"])
destination = Path(record["destination"])
if source.exists():
print(f"Пропуск: исходный путь занят: {source}")
continue
if not destination.is_file():
print(f"Пропуск: нет перемещённого файла: {destination}")
continue
source.parent.mkdir(parents=True, exist_ok=True)
shutil.move(destination, source)
print(f"Возвращён: {destination} -> {source}")
Запускайте его только с журналом этого учебного действия: python undo_sort.py sample/sorted/move-manifest.json. Сначала сохраните копию каталога, если файлы менялись после сортировки. Скрипт намеренно не удаляет пустые папки и журнал — это можно сделать вручную после проверки.
Журнал не является резервной копией: он хранит пути, а не содержимое. Если файл изменён или удалён после перемещения, восстановить прежние данные по этому журналу невозможно.
Официальная документация shutil описывает высокоуровневые файловые операции. В последнем уроке маршрута те же принципы — явные параметры, журнал, проверяемый результат и код выхода — будут применены к мониторингу HTTP.
Самопроверка
Проверьте, что материал усвоен
Ответьте на все вопросы. Результат сохранится только в этом браузере и будет учтён в статистике прочитанных материалов.
Разбираем коротко
Частые вопросы
Почему сортировщик по умолчанию ничего не перемещает?
Режим предварительного просмотра позволяет проверить исходные и конечные пути. Изменения начинаются только с явным флагом --apply.
Что происходит при совпадении имени?
Программа останавливается до перемещения и не заменяет существующий файл. Пользователь должен сам решить, переименовать источник или освободить целевой путь.
Гарантирует ли журнал полный откат?
Нет. Он помогает вернуть выполненные перемещения, если файлы после этого не менялись. Перед важной массовой операцией всё равно нужна отдельная резервная копия.


