CLI-утилита для рекурсивной нормализации имён файлов и папок: транслитерация в ASCII, приведение дат к ISO-формату, единый стиль разделителей и регистра. Поведение идемпотентно — повторный запуск над уже нормализованным деревом ничего не переименовывает (единственное, что меняется при повторе, — служебный журнал .fs-log, который намеренно дополняется на каждом запуске; см. «Журнал»).
- Рекурсивный обход каталога с переименованием файлов и папок.
- Транслитерация не-ASCII символов (кириллица, умляуты, emoji и т.п.) в ASCII. Мягкий и твёрдый знаки (
ь/ъ) удаляются, а не превращаются в апостроф; запрещённые в именах файлов Windows символы (< > : " | ? *), которые может породить транслитерация типографики («»→<</>>), вырезаются. - Обработка круглых и квадратных скобок: с числом (дубли файловых менеджеров
Файл (1),Файл [1]) — скобки вырезаются, число дополняется ведущим нулём (→fail-01); с текстом (инн (Нового договора нет)) — скобки сохраняются. Непарные/несовпадающие скобки (Файл (1,инн (текст]) вырезаются как невалидные. - Распознавание дат в разных форматах и приведение к ISO (
YYYY-MM-DD) с плейсхолдерами00для недостающих компонентов. - Ведущий ноль для однозначных числовых токенов (
1_file→01_file). - Пробелы → дефис; цепочки дефисов вокруг пробелов схлопываются в один (
Резюме - подготовка→reziume-podgotovka), но намеренные дефисы без пробелов сохраняются (file--improved). Обрезка «мусора» по краям имени (ведущий_сохраняется и у файлов, и у папок, на краю сохраняется парная круглая скобка), единый регистр (папки — с заглавной, файлы — в нижнем; общепринятые имена вродеREADMEсохраняют регистр; у папок ведущий_сохраняется, а капитализируется первая буква после него —_private→_Private). - Расширение файла не изменяется.
- Скрытые файлы и папки (начинающиеся с
.) пропускаются и не обходятся. - Фильтр путей
.fs-ignoreв стиле.gitignore(в корне нормализуемого каталога): обычная строка исключает объект из нормализации, строка с!— возвращает (override). Полный синтаксис gitignore (*,**,?,[abc], завершающий/, якоря), порядок строк значим. Без правила-!внутрь исключённых каталогов не заходим, такие объекты не учитываются в подсчёте (см. «Фильтр путей»). - Журнал
.fs-logв нормализуемом каталоге: после прогона дописывается блок с датой-временем и списком выполненных переименований (только успешных — ошибки и конфликты в журнал не пишутся). Файл создаётся при отсутствии и дополняется при повторных запусках (см. «Журнал»). - Корневой каталог не переименовывается.
- Безопасная обработка конфликтов: если целевое имя уже занято другим объектом, переименование пропускается (данные не перезаписываются).
- Имя всегда остаётся одним компонентом пути: разделители (
/,\) и управляющие символы, которые может ввести транслитерация, заменяются на-— объект нельзя случайно переместить в другой каталог или потерять. - Выбор каталога: нативный проводник Windows (на Windows и в WSL), стандартный диалог macOS либо ввод пути в терминале (на обычном Linux).
fs-normalizer/
├── normalize_fs.py # точка входа
├── normalize.sh # обёртка для Linux/macOS (терминал)
├── normalize.command # обёртка для macOS (двойной клик в Finder)
├── normalize.bat # обёртка для Windows
├── normalizer/
│ ├── __init__.py # публичное API
│ ├── cli.py # разбор аргументов и сценарий запуска
│ ├── picker.py # выбор каталога (диалоги Windows/WSL/macOS, ввод в терминале)
│ ├── pick_folder.ps1 # нативный диалог выбора папки Windows (IFileOpenDialog)
│ ├── filesystem.py # обход ФС и применение переименований (deepest-first)
│ ├── ignore.py # фильтр путей .fs-ignore (gitignore-семантика, pathspec)
│ ├── log.py # журнал .fs-log: дата + список выполненных переименований
│ ├── name.py # сборка нового имени из конвейера правил
│ ├── rules/ # пакет правил: по файлу на правило
│ │ ├── __init__.py # ре-экспорт Rule и правил (__all__)
│ │ ├── base.py # Rule — базовый интерфейс
│ │ ├── transliteration.py # транслитерация в ASCII + барьер безопасности
│ │ ├── brackets.py # скобки с числом/датой убираются
│ │ ├── date.py # даты -> ISO YYYY-MM-DD
│ │ ├── leading_zero.py # ведущий ноль для одиночной цифры
│ │ ├── space_to_dash.py # пробелы -> дефис
│ │ ├── trim_edge.py # обрезка мусора по краям имени
│ │ └── case.py # регистр: папки/файлы
│ └── safety.py # барьеры безопасности имени (один компонент пути)
├── tests/ # тесты (pytest)
├── examples/ # песочница-фикстуры для ручного прогона
│ └── reset.sh/.command/.bat # откат прогона по examples/ (git reset + clean + checkout, ignorecase=false; + rm .fs-log)
└── requirements.txt
- Python 3.10+ (используется синтаксис
X | None). - Рантайм-зависимости (
requirements.txt): Unidecode — транслитерация, pathspec — фильтр путей.fs-ignore.
Стандартной библиотеки достаточно для выбора папки: на Windows и в WSL вызывается powershell.exe (нативный диалог IFileOpenDialog), на macOS — osascript (AppleScript choose folder), на обычном Linux — ввод пути в терминале. Дополнительных GUI-пакетов не требуется.
Через обёртки установка не нужна: при первом запуске они сами создают .venv и ставят зависимости (требуется интернет один раз). Этот раздел — для прямого запуска python3 normalize_fs.py или разработки:
cd fs-normalizer
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txtПроще всего — через обёртку для своей ОС. При первом запуске она автоматически подготовит окружение (.venv + зависимости), затем запустит утилиту:
./normalize.bat # Windows
./normalize.sh # Linux/macOS (терминал)На macOS для запуска двойным кликом в Finder используйте normalize.command (один раз сделайте его исполняемым: chmod +x normalize.command).
Альтернатива — прямой запуск (требует заранее подготовленного окружения, см. «Установка»):
python3 normalize_fs.pyАргументы не нужны — каталог выбирается интерактивно. Поведение зависит от среды (в скобках — папка, предлагаемая по умолчанию):
- Windows — открывается нативный диалог выбора папки (
IFileOpenDialog,FOS_PICKFOLDERS). Он сразу открывается на папке по умолчанию, при этом навигация по всем путям остаётся свободной. По умолчанию это рабочий каталог: при запуске из командной строки — каталог вызова, а при запуске через ярлык/двойной клик по.bat— его рабочая папка (для двойного клика она автоматически равна каталогу проекта; для ярлыка.lnkзадайте поле «Рабочая папка» = каталог проекта). - WSL — открывается тот же нативный диалог Windows, сразу на каталоге вызова (через UNC-путь
\\wsl.localhost\...); выбранный путь автоматически переводится обратно в путь WSL. Навигация по всем путям (включая узел «Linux» и сеть) свободна. - macOS — открывается стандартный системный диалог
choose folder(черезosascript), возвращается POSIX-путь; по умолчанию предлагается каталог, из которого вызвана утилита (с откатом на каталог проекта). - Обычный Linux — запрашивается ввод пути к каталогу в терминале; по умолчанию (пустой ввод) используется каталог, из которого вызвана утилита.
О диалоге Windows/WSL. Используется системный
IFileOpenDialogв режиме выбора папки:SetFolderвместе с уникальнымSetClientGuidжёстко открывает диалог на нужной папке (игнорируя запомненную последнюю папку — MRU) и принимает UNC-путь WSL, сохраняя свободную навигацию. СтандартныеOpenFileDialog/BrowseForFolderэтого не дают: первый игнорирует стартовую папку для UNC (открывает «Документы»), второй не принимает UNC как корень. ФлагFOS_FORCEFILESYSTEMнамеренно не ставится: корневой узел «Linux» (\\wsl.localhost) не считается элементом файловой системы, и с этим флагом он исчез бы из дерева — то есть из-под Windows нельзя было бы добраться до папок WSL. Из WSL путь к Windows-процессу прокидывается через переменнуюWSLENV(иначе пользовательские переменные окружения не доходят доpowershell.exe). Небольшой блок интеропа на C# вынесен в отдельный файлnormalizer/pick_folder.ps1и исполняется черезpowershell.exe.
После завершения выводится сводка: сколько объектов переименовано и сколько пропущено.
Чтобы не указывать полный путь к обёртке каждый раз, добавьте алиас в конфиг своей оболочки.
bash/zsh — в ~/.bashrc или ~/.zshrc:
alias fsnorm="$HOME/Projects/Work/fs-normalizer/normalize.sh"Примените изменения (source ~/.bashrc) — и запускайте командой fsnorm.
PowerShell — в файле профиля ($PROFILE). Используется функция-обёртка, чтобы корректно пробрасывать аргументы:
function fsnorm { & "C:\path\to\fs-normalizer\normalize.bat" @args }Перечитайте профиль (. $PROFILE) — и запускайте командой fsnorm.
Чтобы исключить отдельные каталоги или файлы из нормализации, положите файл .fs-ignore в сам нормализуемый каталог (тот, что выбираете при запуске) и перечислите их в нём. Файл читается из этого каталога — ровно как .gitignore в корне репозитория. Формат — как у .gitignore (под капотом библиотека pathspec): обычная строка исключает, строка с ведущим ! — возвращает (override) ранее исключённое. Файл не создаётся автоматически: нет файла — фильтр выключен; если каталог под git, удобно добавить .fs-ignore в .gitignore, чтобы список оставался локальным.
Пути сопоставляются относительно нормализуемого каталога, и якорь / указывает на его верхушку (туда же, где лежит .fs-ignore). Пустые строки и комментарии (строка, начинающаяся с #) игнорируются. Пример: исключаем архивы и сборки, но возвращаем папки Data и не-скрытые файлы *.keep внутри них.
# Каталоги и файлы, которые не нормализуем
Archive
build/
*.bak
Projects/*/out
# Возвращаем обратно (override): порядок строк важен — выигрывает последняя совпавшая
!**/Data/
!*.keep
Как это работает:
- Синтаксис gitignore.
*— любые символы в пределах одного сегмента (не пересекает/);**— ноль или более сегментов (Projects/**/Data);?— ровно один символ;[abc]/[a-z]— класс символов; завершающий/(build/) исключает только каталог (и его содержимое), но не одноимённый файл. Разделитель — только/;\— символ экранирования (литеральные скобки/?пишите какФайл \[1\]). - Без
./и про#. Префикс./не поддерживается (.— обычный сегмент, паттерн молча ни с чем не совпадёт): пишитеfoo(basename) или/foo(якорь). Комментарий — только строка, начинающаяся с#; срединный#литерален, поэтомуC#/ProjectsиC++/Projectsвалидны (а литеральный ведущий#экранируйте как\#). - Якоря. Паттерн без
/(или только с завершающим/) — это basename:Archiveсовпадёт на любой глубине (.../Archive/...), но не сMyArchive/Archive2. Паттерн с/внутри (или ведущим/) привязан к корню нормализации:Home/Componentsсовпадёт только сHome/Componentsот верхушки выбранного каталога, аSub/notes.txt— только в этой цепочке. - Работает и для файлов.
notes.txtисключит такой файл в любом месте дерева по границе сегмента (mynotes.txtне затрагивается). - Отрицание (
!) и порядок. Строка с!возвращает к нормализации то, что убрали предыдущие строки. Решение по каждому пути принимает последняя совпавшая строка (как в git). Это позволяет «исключить каталог, но вернуть в нём отдельные файлы». - Скрытые — раньше фильтра. Объекты с именем на
.(например,.git,.keep) пропускаются до применения.fs-ignoreи внутрь них обход не заходит, поэтому правило-!их вернуть не может. Паттерн*.keepдействует только на не-скрытые файлы (marker.keep), но не на файл с именем.keep. - Исключённое не считается. Пропущенные объекты не попадают в итоговый счётчик (ни в «переименовано», ни в «пропущено»). Если в
.fs-ignoreнет ни одной!-строки, исключённый каталог не обходится (его содержимое не трогаем). Как только появляется хотя бы одно правило-!, обход заходит внутрь исключённых каталогов — там могут быть возвращённые потомки. - Регистронезависимость. Матчинг не учитывает регистр (как git с
core.ignorecase=true— поведение по умолчанию на Windows и macOS):Archiveсовпадает сarchive,*.txt— сNotes.TXT. Это нужно, чтобы капитализация вышележащих каталогов (file-glob→File-glob, правило регистра) не рвала якоря паттернов и повторный запуск над уже нормализованным деревом ничего не менял. Замечание: не-ASCII имя родителя транслитерируется целиком (Документы→Dokumenty), и регистронезависимость такой случай не покрывает. - Кросс-платформенно. Пути относительные и не содержат буквы диска, поэтому один и тот же
.fs-ignoreработает одинаково на Windows, WSL, Linux и macOS. - Без файла — обычное поведение. Если
.fs-ignoreотсутствует, фильтр выключён. Пустой файл (или только из комментариев) ничего не исключает.
Рабочий пример с разными типами паттернов — в examples/.fs-ignore (разбор — в секции «10-ignore» файла examples/README.md): якоря с ведущим / и без, исключение каталога с возвратом потомка через !, и basename.
После каждого прогона в нормализуемый каталог дописывается журнал .fs-log: блок с датой-временем запуска (ISO, YYYY-MM-DD HH:MM:SS) и списком выполненных переименований (относительные пути от корня, old -> new). В журнал попадают только успешно выполненные переименования — ошибки и конфликты (занятое целевое имя) туда не пишутся, они выводятся в stderr. Если за прогон ничего не переименовано, фиксируется блок с пометкой (изменений нет). Файл создаётся при отсутствии, при повторных запусках — дополняется (новые блоки разделены пустой строкой).
Порядок записей — как выполнялись переименования (deepest-first: дети раньше родителей), поэтому в пути дочернего объекта родитель ещё со старым именем — это точно отражает фактические операции:
2026-06-11 13:39:00
Отчёт за март/Файл (1).docx -> Отчёт за март/fail-01.docx
Отчёт за март -> Otchiot-za-mart
1_file.TXT -> 01_file.TXT
2026-06-11 14:02:11
(изменений нет)
Сам .fs-log скрыт (имя на .), поэтому обходом пропускается: не нормализуется и не матчится .fs-ignore. Если каталог под git, файл удобно добавить в .gitignore (в этом репозитории он уже добавлен).
Журнал — намеренное исключение из инварианта идемпотентности: он дополняется на каждом запуске (блок с датой, при отсутствии переименований — (изменений нет)), тогда как сами переименования идемпотентны. Журнал пишет CLI после прохода, а не сам FilesystemNormalizer, поэтому проверки идемпотентности (которые гоняют FilesystemNormalizer.apply напрямую) на него не опираются. После демо-прогона по examples/ журнал убирают обёртки reset.* (явным rm, т.к. игнорируемый git-ом файл git clean -fd не удаляет).
Правила применяются к имени (без расширения для файлов) строго в указанном порядке:
| # | Правило | Что делает |
|---|---|---|
| 1 | TransliterationRule |
Любой не-ASCII символ → ASCII (мягкий/твёрдый знаки ь/ъ удаляются, а не дают апостроф); разделители пути (/, \) и управляющие символы, которые может породить транслитерация (½→1/2, ∖→\, U+2028→перевод строки), заменяются на -, чтобы имя осталось одним компонентом пути; запрещённые на Windows символы (`< > : " |
| 2 | BracketsRule |
Круглые и квадратные скобки с числом/датой (без букв) → убираются ((1)/[1]→1, дальше ведущий ноль); скобки с текстом → сохраняются; непарные/несовпадающие → вырезаются |
| 3 | DateRule |
Даты → ISO YYYY-MM-DD; недостающие части → 00 |
| 4 | SpaceToDashRule |
Пробелы → дефис; цепочки дефисов вокруг пробелов схлопываются в один, но намеренные дефисы без пробелов (file--improved) сохраняются |
| 5 | TrimEdgeRule |
Обрезка не буквенно-цифровых символов по краям (ведущий _ сохраняется и у файлов, и у папок; парная скобка на краю сохраняется; + и # — символы имени и сохраняются по краям: C#, C++, F#, notepad++) |
| 6 | LeadingZeroRule |
Однозначный числовой токен → с ведущим нулём |
| 7 | CaseRule |
Папки — с заглавной буквы, файлы — в нижнем регистре (кроме общепринятых имён вроде README, чей регистр сохраняется); у папок ведущий _ сохраняется, капитализируется первая буква после него (_private → _Private) |
Порядок важен: LeadingZeroRule идёт после TrimEdgeRule (ведущий ноль добавляется к уже очищенному от кромочного «мусора» токену — иначе том 5! → tom-5 на первом проходе и tom-05 на втором, нарушая идемпотентность); CaseRule идёт последним (после схлопывания пробелов и обрезки кромок), что обеспечивает корректную капитализацию за один проход и идемпотентность.
Файлы:
| Исходное имя | Результат |
|---|---|
Отчёт.TXT |
otchiot.TXT |
Письмо.doc |
pismo.doc |
ООО «Печоралифтсервис».docx |
ooo-pechoraliftservis.docx |
Файл (1).docx |
fail-01.docx |
Файл [1].docx |
fail-01.docx |
инн (Нового договора нет).txt |
inn-(novogo-dogovora-net).txt |
инн [Нового договора нет].txt |
inn-[novogo-dogovora-net].txt |
Файл (1.docx |
fail-01.docx |
инн (Нового договора нет.txt |
inn-novogo-dogovora-net.txt |
Резюме - подготовка.txt |
reziume-podgotovka.txt |
file--improved.txt |
file--improved.txt |
10½.dat |
10-01-02.dat |
1_file.TXT |
01_file.TXT |
v2 readme.MD |
v2-readme.MD |
20.05.2020_dump |
2020-05-20_dump |
05.2020_report |
2020-05-00_report |
2020 |
2020-00-00 |
-file_01-.png |
file_01.png |
_private.TXT |
_private.TXT |
C++.txt |
c++.txt |
notepad++ |
notepad++ |
README.md |
README.md |
Папки:
| Исходное имя | Результат |
|---|---|
отчёт за март |
Otchiot-za-mart |
Отчёт 2020 |
Otchiot_2020-00-00 |
отчёт |
Otchiot |
_private |
_Private |
C# |
C# |
C++ |
C++ |
Импортируйте из пакета normalizer, а не из подмодулей напрямую:
from pathlib import Path
from normalizer import build_normalizer, FilesystemNormalizer, load_fs_ignore, write_fs_log
normalizer = build_normalizer()
normalizer.normalize("Отчёт 2020", is_dir=True) # 'Otchiot_2020-00-00'
# Фильтр читается из самого нормализуемого каталога (target/.fs-ignore);
# load_fs_ignore вернёт None, если файла нет (фильтр выключен).
target = Path("/path/to/dir")
ignorer = load_fs_ignore(target) # стиль .gitignore, якоря от target
fs = FilesystemNormalizer(build_normalizer(), ignorer)
renamed, skipped = fs.apply(target)
write_fs_log(target, fs.renames) # дописать .fs-log (только выполненное)Экспортируется: main, FilesystemNormalizer, FsIgnore, load_fs_ignore, FS_LOG, write_fs_log, NameNormalizer, build_normalizer, Rule, TransliterationRule, BracketsRule, DateRule, LeadingZeroRule, CaseRule, SpaceToDashRule, TrimEdgeRule.
pytest # тесты
ruff check . # линтер
mypy --strict normalizer normalize_fs.py # проверка типовКод проходит ruff и mypy --strict без замечаний; покрытие правил и сценариев ФС — в tests/.
Скрипты-обёртки (normalize.sh, normalize.command) и normalize_fs.py хранятся в репозитории с битом исполняемости (100755), нужным на Linux/macOS. Файловая система Windows такой бит не хранит, поэтому git status/git diff на Windows постоянно показывают изменение режима (old mode 100755 / new mode 100644), которое не убирается даже через git checkout --.
Чтобы Git на Windows игнорировал режим файлов, выполните один раз в клоне:
git config core.fileMode falseЭто локальная настройка конкретного клона; содержимое файлов Git по-прежнему отслеживает, а Unix-пользователей она не затрагивает.