Проект — набор кросс-платформенных CLI-утилит для обслуживания файловой системы,
собранный в один устанавливаемый пакет с общим ядром (shared/): выбор каталога, доступ
к единому .env, журнал .fs-log.log и веб-хук уведомления реализованы один раз и
переиспользуются всеми режимами, а не дублируются в каждом.
Репозиторий можно использовать как:
- Готовый инструментарий — четыре независимых режима CLI, каждый со своим extra
(
pip install -e ".[normalizer]"и т.д.), которые можно ставить и запускать по отдельности или все сразу через единый диспетчерfs-tools; - Референс архитектуры — как построить симметричный набор read-only проверок
(
checker/schemer) и мутирующих режимов (normalizer/syncher) на общем ядре, с единым контрактом терминального вывода, кодов возврата и журналирования; - Отправную точку — скопировать структуру (
src/fs_tools/shared/, режимный пакет,tests/,examples/) под собственный набор файловых утилит и адаптировать/отключить готовые режимы.
Режимы:
| Режим | Команда | Что делает |
|---|---|---|
| Нормализация имён | fs-normalizer |
Рекурсивно приводит имена файлов и папок к единому виду: транслитерация в ASCII, даты в ISO, единый стиль разделителей и регистра. Идемпотентна: повторный прогон над уже нормализованным деревом ничего не меняет (кроме служебного журнала) |
| Проверка структуры | fs-checker |
Рекурсивно сверяет дерево с правилами .fs-checker (синтаксис как у .gitignore, но смысл инвертирован: перечисленное обязано существовать) и печатает список отсутствующих путей. Структуру не меняет |
| Синхронизация | fs-syncher |
Односторонняя синхронизация каталога с сервером (ПК → сервер) через внешний rsync по декларативному .fs-syncher.toml. Состав передачи, зеркалирование удалений (с защитой delete-guard) и выгрузка-offload задаются профилями |
| Проверка схемы | fs-schemer |
Рекурсивно сверяет базу знаний с декларативными группами .fs-schemer.toml: обязательные/опциональные служебные файлы, точный текст заданной строки, запрет пустых групповых папок и файлов вне них. Структуру не меняет |
Все режимы доступны и по отдельности, и через единый диспетчер fs-tools.
- Python 3.11+.
- Базовая зависимость: pathspec (gitignore-семантика
для
.fs-normalizerи негативов.fs-checker). - Опциональные зависимости по режимам (extras):
normalizer→ Unidecode (транслитерация);checker→ requests, python-dotenv (веб-хук и.env);syncher→ requests, python-dotenv (веб-хук и.env);schemer→ requests, python-dotenv (веб-хук и.env).
- Внешние бинарники для синхронизации:
rsync(обязателен),ssh(для SSH-целей).
Тяжёлые зависимости подгружаются лениво: можно поставить только нужный extra, и
другие режимы не будут требовать чужих пакетов. Для выбора каталога GUI-пакеты не нужны:
Windows/WSL — нативный IFileOpenDialog через powershell.exe, macOS — osascript,
обычный Linux — ввод пути в терминале.
Проект рассчитан на локальную editable-установку (не на публикацию в PyPI):
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e ".[normalizer,checker,syncher,schemer]" # все режимы
# или частично:
pip install -e ".[normalizer]" # только нормализатор
pip install -e ".[checker]" # только проверка
pip install -e ".[syncher]" # только синхронизация
pip install -e ".[schemer]" # только проверка схемы
pip install -e ".[normalizer,checker,syncher,schemer,dev]" # + инструменты разработкиПосле установки доступны команды fs-normalizer, fs-checker, fs-syncher,
fs-schemer, fs-tools (и python -m fs_tools).
Для запуска fs-syncher напрямую из Windows установите rsync через Chocolatey:
- Установите Chocolatey по официальной инструкции: https://chocolatey.org/install#individual
- Откройте терминал от имени администратора и установите rsync:
choco install rsync -y- Проверьте доступность бинаря:
rsync --versionЕсли rsync не найден в IDE-терминале после установки, полностью перезапустите IDE
и создайте новый терминал (чтобы обновился PATH).
Для запуска без ручной подготовки окружения есть обёртки в bin/: при первом
запуске они создают .venv в корне проекта, ставят пакет со всеми extra и экспортируют
FS_TOOLS_HOME (нужно для поиска единого .env).
bin/normalize.sh [каталог] # Linux/macOS (терминал) → fs-normalizer
bin/check.sh [каталог] # Linux/macOS (терминал) → fs-checker
bin/sync.sh [каталог] [флаги] # Linux/macOS (терминал) → fs-syncher
bin/scheme.sh [каталог] # Linux/macOS (терминал) → fs-schemer
bin/normalize.command # macOS (двойной клик в Finder)
bin/check.command # macOS (двойной клик в Finder)
bin/sync.command # macOS (двойной клик в Finder)
bin/scheme.command # macOS (двойной клик в Finder)
bin/normalize.bat [каталог] # Windows → fs-normalizer
bin/check.bat [каталог] # Windows → fs-checker
bin/sync.bat [каталог] [флаги] # Windows (через WSL/cwrsync) → fs-syncher
bin/scheme.bat [каталог] # Windows → fs-schemerБез аргумента каталог выбирается интерактивно (по умолчанию предлагается рабочий каталог). Каталог можно передать аргументом — тогда диалог не открывается:
fs-normalizer # нормализация: выбрать каталог в диалоге
fs-normalizer /path/to/dir # без диалога
fs-normalizer /path/to/dir --dry-run
fs-checker # проверка: выбрать каталог в диалоге
fs-checker /path/to/dir # без диалога
fs-syncher # синхронизация: выбрать каталог в диалоге
fs-syncher /path/to/dir # без диалога
fs-schemer # проверка схемы: выбрать каталог в диалоге
fs-schemer /path/to/dir # без диалога
fs-tools normalize /path/to/dir # то же через диспетчер
fs-tools normalize /path/to/dir --dry-run
fs-tools check /path/to/dir
fs-tools sync /path/to/dir
fs-tools scheme /path/to/dir
python -m fs_tools normalize # эквивалент fs-tools normalizeФлаг -c/--config PATH (во всех режимах) указывает файл конфигурации с
произвольным именем — так в одной папке можно держать несколько конфигов под
разными именами. Корнем запуска становится каталог этого файла, поэтому
позиционный каталог не нужен (а если задан вместе с --config — игнорируется):
fs-schemer --config /home/kb/prod.fs-schemer.toml # корень = /home/kb, конфиг = prod.fs-schemer.toml
fs-checker -c /home/kb/ci.fs-checker # корень = /home/kb, правила = ci.fs-checker
fs-syncher -c /home/kb/nightly.fs-syncher.toml # корень = /home/kb, профили = nightly.fs-syncher.toml
fs-tools scheme -c /home/kb/staging.fs-schemer.toml # то же через диспетчерАктивный файл конфига (даже с видимым именем) не обрабатывается самим режимом: нормализатор его не переименовывает, синхронизация не отправляет на сервер, проверка схемы не считает его «файлом вне групповой папки» — так же, как исключается скрытый конфиг по имени по умолчанию.
Запуск по таймеру — с фиксированным путём в команде, поэтому стартовый рабочий каталог не важен:
# crontab -e
0 3 * * * /path/to/fs-tools/bin/normalize.sh /mnt/disk/Home # нормализация ночью
0 9 * * * /path/to/fs-tools/bin/check.sh /mnt/disk/Home # проверка утром
0 1 * * * /path/to/fs-tools/bin/sync.sh /mnt/disk/Home # синхронизация ночью
0 7 * * * /path/to/fs-tools/bin/scheme.sh /mnt/disk/Home # проверка схемы утромПрименяются к имени (для файлов — без расширения) строго по порядку:
| # | Правило | Что делает |
|---|---|---|
| 1 | TransliterationRule |
Не-ASCII → ASCII (ь/ъ удаляются, апострофа не дают); разделители пути и управляющие символы из транслитерации (½→1/2, ∖→\) → -; запрещённые на Windows < > : " | ? * (из «»→<</>>) вырезаются |
| 2 | BracketsRule |
Скобки с числом/датой → убираются ((1)/[1]→1); с текстом → сохраняются; непарные/несовпадающие → вырезаются |
| 3 | DateRule |
Даты → ISO YYYY-MM-DD; недостающие части → 00 |
| 4 | SpaceToDashRule |
Пробелы → дефис; цепочки вокруг пробела схлопываются, намеренные file--improved сохраняются |
| 5 | TrimEdgeRule |
Обрезка «мусора» по краям (ведущий _ сохраняется; парная скобка на краю сохраняется; +/# — символы имени: C#, C++, notepad++) |
| 6 | LeadingZeroRule |
Одиночный числовой токен → с ведущим нулём (1_file→01_file) |
| 7 | CaseRule |
Папки — с заглавной, файлы — в нижнем регистре (README сохраняется); у папок ведущий _ сохраняется (_private→_Private) |
Порядок важен: LeadingZeroRule после TrimEdgeRule, CaseRule — последним; это
обеспечивает корректность и идемпотентность за один проход. Расширение файла не
меняется. Скрытые объекты (на .) и корневой каталог не трогаются.
Примеры: Отчёт.TXT→otchiot.TXT, Файл (1).docx→fail-01.docx,
20.05.2020_dump→2020-05-20_dump, отчёт за март→Otchiot-za-mart (папка).
Положите файл .fs-normalizer в нормализуемый каталог (как .gitignore в корне репозитория).
Полный синтаксис gitignore поверх pathspec: *, **, ?, [abc], завершающий /,
якоря (/foo — от корня, foo — basename на любой глубине), ! — возврат (override),
порядок строк значим. Матчинг регистронезависим. Нет файла → фильтр выключен. Без правил-!
внутрь исключённых каталогов обход не заходит; исключённые объекты в счётчики не попадают.
Файл ЕСТЬ, но не удалось прочитать (нет прав, гонка удаления) — это не «нет файла»:
прогон останавливается кодом 1 (стало симметрично checker/schemer), а не тихо
переименовывает исключённое.
| Код | Условие |
|---|---|
| 0 | прогон без реальных ошибок (безопасно пропущенные конфликты входят сюда) |
| 1 | ошибка запуска: каталог не выбран / не найден / не каталог, не установлен extra normalizer (Unidecode), либо .fs-normalizer есть, но не удалось прочитать |
| 2 | часть переименований не удалась (OSError: напр. зарезервированные имена Windows, длина пути) |
Конфликт (занятое целевое имя) — безопасный пропуск, на код возврата не влияет.
--dry-run строит план нормализации без переименования объектов. В отчёте режим
помечается как dry-run. В этом режиме .fs-log.log содержит планируемые изменения.
from pathlib import Path
from fs_tools.normalizer import build_normalizer, FsNormalizer, load_fs_ignore, write_fs_log
build_normalizer().normalize("Отчёт 2020", is_dir=True) # 'Otchiot_2020-00-00'
target = Path("/path/to/dir")
normalizer = FsNormalizer(build_normalizer(), load_fs_ignore(target))
renamed, skipped = normalizer.apply(target)
write_fs_log(target, normalizer.renames) # дописать .fs-log.log
renamed, skipped = normalizer.apply(target, dry_run=True) # только план, без rename
normalizer.planned # пары src -> dst для dry-runФайл лежит в корне проверки и читается оттуда. Синтаксис близок к .gitignore, но
перечисленное обязано существовать.
- Кодировка
utf-8-sig; разделитель сегментов — POSIX/. Комментарий — только ведущий#; пустые строки игнорируются; конечные пробелы обрезаются (значимый —\). - Сегменты: литерал,
*(один уровень),**(ноль и более), глоб внутри сегмента. - Мандат — последний сегмент. Завершающий
/→ строго каталог (is_dir()); без него —exists()(файл или папка). - Негативы
!работают через единый ordered pathspec-канал: матч по относительным путям якоря и мандата (anchor/require), поддержка масок**,*,?,[], порядок строк значим (last-match-wins). - В checker
!— только исключение из проверки: re-include не используется. Ведущие!схлопываются, поэтому!!/Code/PHP/**трактуется как!/Code/PHP/**.
# фиксированные каталоги — отдельными правилами-литералами
/Activities
/Activities/Web/Projects
# подстановка: в каждом занятии
/Activities/*/Projects
# строго каталог (завершающий /)
/Activities/Web/Projects/Addl/
# обязательный файл (мандат-файл)
/Activities/Web/Projects/Work/*/*/Data/project.md
# архивные проекты на любом уровне
/Activities/Web/Projects/**/_Archive/*/Back
# short pathspec-паттерн: исключить ветки _Archive на любой глубине
!_ArchiveФиксированные промежуточные каталоги описывают отдельными правилами-литералами: иначе их отсутствие маскирует более глубокие нарушения (нет якоря — нечего проверять).
Каталог: /mnt/disk/Home
Отсутствуют пути (4):
Activities/3D/Resources
Activities/Web/Projects/Addl/safegrid.example/Data
Activities/Web/Projects/Self/personal.example/Back
Activities/Web/Projects/Work/Fabrikam/widgets.example/Data/project.md
Статус: warn. Найдены отсутствующие пути.
Сводка: проверено правил: 17; найдено каталогов-кандидатов: 22; отсутствует: 4; ошибок чтения: 0.
Каталог с ** в префиксе, который не удалось просканировать (нет прав, гонка
удаления), — это не «нет якорей» (штатный случай), а техническая ошибка: отдельным
блоком Ошибки чтения (N): с пометкой (ОШИБКА), статус повышается до error., а
код возврата — до 2, даже если Отсутствуют пути пуст (иначе непросканированное
поддерево тихо выпало бы из проверки).
| Код | Условие |
|---|---|
| 0 | нарушений нет |
| 1 | ошибка запуска: каталог не выбран / не найден / не каталог, нет .fs-checker |
| 2 | проверка выполнена: найдены отсутствующие пути и/или ошибки сканирования |
Проверка всегда дописывает журнал .fs-log.log; при нарушениях дополнительно шлёт
fire-and-forget веб-хук
(POST {"text": ...} с заголовком Authorization: Bearer <токен>, если токен задан;
сетевые ошибки гасятся и на код возврата не влияют).
Конфигурация — в едином .env проекта. Путь: FS_TOOLS_HOME/.env (переменную
экспортируют обёртки bin/*), при отсутствии переменной — .env в текущем рабочем
каталоге. Шаблон — .env.example:
FSCHECKER_WEBHOOK_URL=https://example.com/hook
FSCHECKER_WEBHOOK_TOKEN=секретный-токенОсобенности конфигурации веб-хука:
- приоритет: переменные окружения процесса важнее значений из
.env; - только HTTPS: не-
https://URL отвергается (токен не уходит по нешифрованному каналу).
Без FSCHECKER_WEBHOOK_URL уведомления отключены; токен необязателен.
from pathlib import Path
from fs_tools.checker import FsChecker, format_report, load_fs_rule
root = Path("/path/to/dir")
checker = FsChecker(load_fs_rule(root)).check(root)
print(format_report(root, checker)) # checker.missing — отсортированный списокРекурсивно сверяет базу знаний с декларативными группами .fs-schemer.toml: дерево
состоит из тематических узлов произвольной глубины, любой из которых может содержать
групповые папки (_Knowledges, _Commands, …) с обязательными/опциональными
служебными файлами и «обычными» файлами группы. Конфиг .fs-schemer.toml (без ведущей
точки) лежит в переданном утилите каталоге и читается оттуда; по умолчанию этот
же каталог и проверяется. Утилита не меняет структуру (read-only).
Если конфиг удобнее держать отдельно от проверяемого дерева (например, все конфиги
утилит — в E:/Home, а база знаний — в E:/Home/Workspace/Warehouse), поле
[defaults].apply_root задаёт каталог, который реально обходится — см. ниже.
[defaults]
apply_root = "Workspace/Warehouse" # опционально: каталог проверки (см. ниже)
exclude_prefix = "_" # префикс "служебных" файлов группы
[[group]]
name = "_Knowledges"
default_rule = { line = 1, text = "# Заметки", extensions = [".md"] } # только .md
[[group.file]]
name = "_main.md" # обязателен (optional не задан)
line = 1
text = "# Заметки"
[[group.file]]
name = "rules.md"
optional = true # опционален; если есть — контент проверяется
line = 3
text = "## Правила"
[[group]]
name = "Learn" # имя папки совпадает с ожидаемым заголовком
default_rule = { line = 1, mode = "folder", type = "contains" } # обычные файлы: имя папки — подстрока строки 1
[[group.file]]
name = "_main.md"
line = 1
mode = "folder"
prefix = "# " # строка 1 == "# " + имя папки (заголовок H1 от имени)
[[group]]
name = "_Resources" # группа без собственных файловых правил — валидно
[[group]]
name = "_Commands"
strict = true # опция: прежнее строгое поведение для этой группы[defaults].apply_root— опционально: путь каталога, который реально обходится и проверяется, если он отличается от каталога с.fs-schemer.toml(абсолютный либо относительный — от каталога конфига). Не задан → каталог проверки = каталог конфига (текущее поведение)..fs-log.logпри этом остаётся рядом с конфигом (не переезжает вapply_root) — чтобы журналы всех режимов можно было держать в одном общем каталоге вместе с конфигами. Несуществующийapply_root— код возврата1.[defaults].check_loose_files— опционально (bool, дефолтtrue): включена ли проверка «файл вне групповой папки» (loose_file).falseотключает её во всём дереве — файлы вне групп игнорируются, проверяются только объявленные группы; удобно, когда групповые папки (_Infи т.п.) — редкие островки в большом дереве обычных файлов.[defaults].exclude_prefix— префикс служебных файлов группы (дефолт_).[[group]].name— basename групповой папки, матчится на любой глубине, регистрозависимо.[[group]].default_rule— контент-правило{line, …}для всех видимых файлов группы, не начинающихся сexclude_prefix— включая файлы с записьюgroup.file(правила накладываются, а не взаимоисключаются). Опциональноextensions(whitelist, напр.[".md"]) и/илиexclude_extensions(blacklist) — сужают круг читаемых файлов по расширению (регистронезависимо); можно задать любое из полей по отдельности, оба сразу (итог — «whitelist минус blacklist») или ни одного (читаются все обычные файлы, как раньше). Не подошедшие под фильтр файлы не читаются и не входят в «проверено файлов» — для бинарных файлов (.docx,.pdf, …) без фильтра это давало ложныйread_error.- Ожидание строки — плоские параметры
prefix+ база +suffix(одинаково вdefault_ruleи в[[group.file]];prefix/suffixпо умолчанию пустые):mode = "text"(дефолт) — база берётся из литералаtext(обязателен и непуст при этом режиме); напр.mode = "text", type = "contains", text = "Заметки", prefix = "# "требует вхождения подстроки «# Заметки» в строкуline;mode = "folder"— база вычисляется от имени папки-предка файла (полеtextпри этом недопустимо); уровень заголовка задаётсяprefix("# "— H1,"## "— H2);level(целое ≥ 0, дефолт0, только приmode = "folder") — на сколько уровней вверх от папки файла брать имя базы:0— сама папка (для групп — групповая),1— её родитель,2— прародитель и т.д. Кейс<домен>/_Inf/_main.md, где заголовок = имя домена, а не группы_Inf:mode = "folder", level = 1, prefix = "# "→# <домен>.levelвыше корня проверки → ошибка конфига (код1);type = "equals"(дефолт) — строкаlineцеликом равна ожиданию (точный заголовок вида «# <Имя>»);type = "contains"— ожидание лишь входит в строкуlineкак подстрока.
[[group.file]]— механизм на конкретное имя файла:optional=false(дефолт) — файл обязателен;optional=true— отсутствие не нарушение, но при наличии контент-проверка обязательна.lineобязателен в каждой записи (приmode = "text"— плюсtext).nameне обязан быть уникален в группе — несколько записей с однимnameвыполняются все независимо (несколько content-проверок одного файла); обязательность файла при дублях — «строже побеждает».[[group]].strict(bool, дефолтfalse) — по умолчанию обход не спускается в подпапки группы:group.file/default_ruleпо-прежнему проверяются для файлов прямо в папке группы, но её вложенная структура (сторонние библиотеки, сэмплы, произвольная организация) целиком исключена из проверки —loose_file(F15) в ней не срабатывает.strict = trueвключает прежнее строгое поведение: подпапки группы заново классифицируются наравне с остальным деревом, и F15 в них работает как обычно (полезно, если внутри конкретной группы вложенность — ошибка, а не норма).
| Тип | Условие |
|---|---|
missing_group_file |
обязательный [[group.file]] отсутствует в группе |
bad_header |
строка line не совпала с ожиданием (mode = text/folder, type) |
missing_line |
файл короче номера строки line |
read_error |
файл не удалось прочитать (нет прав, не-UTF-8 содержимое и т.п.) — (ОШИБКА) |
empty_group |
групповая папка не содержит ни одного видимого файла (в т.ч. вложенных) |
loose_file |
файл лежит напрямую в тематическом узле, минуя групповые папки |
.fs-schemer.toml в корне проверки под loose_file не попадает — он скрытый (ведущая
точка), как и .fs-syncher.toml/.fs-checker.
Каталог: /mnt/disk/Warehouse
Статус: error. Найдены нарушения структуры/контента.
Сводка: проверено групп: 6; проверено файлов: 9; нарушений: 5.
В терминал попадает только это (минимум информации); полный список нарушений (тип +
путь, для контентных — ожидание/факт) дописывается в .fs-log.log, см. раздел
«Журнал .fs-log.log».
| Код | Условие |
|---|---|
| 0 | нарушений нет |
| 1 | ошибка запуска: каталог не выбран / не найден / не каталог, нет/невалиден .fs-schemer.toml (в т.ч. несуществующий apply_root) |
| 2 | проверка выполнена, найдены нарушения |
Проверка всегда дописывает журнал .fs-log.log; при нарушениях дополнительно шлёт
fire-and-forget веб-хук (симметрично fs-checker). Конфигурация — в едином .env
проекта, шаблон — .env.example:
FSSCHEMER_WEBHOOK_URL=https://example.com/hook
FSSCHEMER_WEBHOOK_TOKEN=секретный-токенБез FSSCHEMER_WEBHOOK_URL уведомления отключены; токен необязателен; только https://.
from pathlib import Path
from fs_tools.schemer import FsSchemer, format_report, load_scheme_config
root = Path("/path/to/dir")
schemer = FsSchemer(load_scheme_config(root)).check(root)
print(format_report(root, schemer)) # schemer.violations — отсортированный списокОдносторонняя синхронизация локального каталога с сервером (ПК → сервер) через
внешний rsync поверх SSH (или в локальный каталог). Утилита — тонкая обёртка над
rsync: читает и валидирует .fs-syncher.toml, транслирует правила include/exclude в
фильтры rsync (сопоставление путей выполняет сам rsync), запускает команду,
разбирает итог, дописывает .fs-log.log и при необходимости шлёт веб-хук.
Без аргумента каталог выбирается интерактивно (диалог проводника на Windows и в WSL, диалог macOS, либо ввод пути в терминале на Linux). Каталог можно передать аргументом — тогда диалог не открывается.
fs-syncher # выбрать каталог и запустить все профили
fs-syncher /path/to/dir # все профили из .fs-syncher.toml
fs-syncher /path/to/dir --dry-run # план без передачи/удаления
fs-syncher /path/to/dir --profile site # только профиль «site» (флаг повторяемый)
fs-tools sync /path/to/dir # то же через диспетчерФлаги: --profile NAME (повторяемый), --all (все профили — поведение по умолчанию),
--dry-run (приоритетнее dry_run профиля), --force-delete (снять delete-guard).
Windows: нативного
rsyncнет — запускайте режим через WSL (рекомендуется) или cwrsync. Пути дляrsyncприводятся к posix.
Конфиг держим в корне E:\Home и задаём относительный источник только для нужной
папки:
[[sync]]
name = "access"
local_root = "Access"
remote_root = "user@host:/mnt/disk/Home/Access"
delete = false
checksum = trueЗапуск:
bin\sync.bat "E:\Home" --dry-run --profile accessТакой профиль синхронизирует только E:\Home\Access и не требует UNC-путей вроде
\\localhost\....
Если данные лежат на Windows-диске, запускайте sync.sh из WSL, а каталог передавайте
в формате /mnt/<disk>/...:
sudo apt update
sudo apt install -y rsync openssh-client
/home/<user>/Home/Components/fs-tools/bin/sync.sh /mnt/e/Home --dry-run --profile accessЗдесь /mnt/e/Home соответствует E:\Home, а local_root = "Access" в
.fs-syncher.toml остаётся относительным и безопасно ограничивает область синхронизации.
WSL монтирует Windows-диски через drvfs (/mnt/e/...): у NTFS нет настоящих Unix-прав,
и stat часто показывает 777. fs-syncher запускает rsync с -a, который сохраняет
права источника — на Linux-сервере появляются 777 у переданных или обновлённых объектов.
Проверка на WSL:
ls -la /mnt/e/Home/<папка>/<файл>
stat -c '%a %n' /mnt/e/Home/<папка>/<файл>Рекомендуемая настройка в .fs-syncher.toml (в [defaults] или в профиле):
[defaults]
chmod = "D755,F644"chmod добавляет rsync --chmod=D755,F644 (каталоги 755, файлы 644 на приёмнике).
При дефолтном preserve_perms = true rsync исправляет и новые, и уже лежащие на сервере
объекты с 777, даже если содержимое не менялось.
preserve_perms = false (rsync --no-perms) не комбинируйте с chmod для drvfs: тогда
--chmod применяется только к переданным файлам, а неизменённые 777 на сервере
останутся. Поле preserve_perms = false без chmod имеет смысл для POSIX→POSIX, когда
нужны права по umask приёмника.
После перехода на один только chmod следующий прогон восстановит уже лежащие на сервере
777. Если в конфиге по-прежнему preserve_perms = false вместе с chmod, неизменённые
файлы с 777 не починятся — смените конфиг или выполните find … -exec chmod … на
приёмнике. checksum = true права не восстанавливает.
Альтернатива без правок конфига — включить metadata в WSL (/etc/wsl.conf,
секция [automount], опция options = "metadata,umask=022"), затем wsl --shutdown.
Эта ошибка появляется, когда локальный путь источника передан в rsync как
E:/..., и он ошибочно трактуется как host:path. В актуальной версии
fs-syncher локальный Windows-путь автоматически нормализуется в локальный формат
/cygdrive/e/...; если ошибка повторяется, обновите пакет до текущей версии.
Для cwrsync-стека rsync и ssh должны работать в одном окружении. Если в системе
одновременно есть Windows OpenSSH и chocolatey-ssh, задайте транспорт явно:
$env:HOME = "/cygdrive/c/Users/<user>"
$env:RSYNC_RSH = "/cygdrive/c/ProgramData/chocolatey/lib/rsync/tools/bin/ssh.exe"
bin\sync.bat "E:\Home" --dry-run --profile accessЕсли ключ защищён passphrase, используйте ssh-agent в выбранном окружении или
выделенный ключ для автоматизированных запусков.
Конфиг лежит в корне синхронизируемого каталога. Разбор — стандартный tomllib.
[defaults]— значения по умолчанию для всех профилей;[[sync]]— профили зеркалирования (ПК → сервер, с зеркалированием удалений);[[backup]]— профили выгрузки-offload (передача + локальное удаление/архив).
Поля профиля:
| Поле | Назначение |
|---|---|
name |
уникальное имя профиля (обязательно) |
local_root |
путь относительно конфига или абсолютный (обязателен, должен существовать) |
remote_root |
user@host:/path, alias:/path из ~/.ssh/config или локальный путь (обязателен; локальный отсчитывается от каталога конфига) |
exclude / include |
списки gitignore-подобных паттернов |
delete |
зеркалить удаления (дефолт: true для sync, false для backup) |
dry_run |
дефолт false |
delete_threshold / delete_threshold_pct |
пороги delete-guard (по количеству — дефолт 100; по доле % — дефолт 25) |
force_delete |
снять delete-guard (дефолт false) |
checksum, compress, partial_progress |
опции передачи (bool) |
bwlimit |
ограничение полосы (строка) |
ssh_opts |
доп. опции ssh (список строк; только для SSH-целей) |
preserve_perms |
сохранять права источника (дефолт true; false → rsync --no-perms; с chmod — только к переданным файлам) |
chmod |
явные права на приёмнике (строка, синтаксис rsync --chmod, напр. D755,F644; для WSL/drvfs — основной обход 777) |
after_push |
только [[backup]]: delete / archive / nothing (дефолт nothing) |
verify |
только [[backup]]: сверка перед offload (дефолт true) |
archive_dir |
только [[backup]]: каталог архива (дефолт <local_root>/../_fs-backup/<profile>/<YYYY-MM-DD>/) |
Поле профиля перекрывает одноимённое из [defaults]. Валидация (ошибка → код 1 с
указанием профиля и поля): уникальность name; существование local_root; безопасный
remote_root (не корень /, непустой путь после :); корректный enum after_push;
типы полей.
Сопоставление путей выполняет сам rsync через --filter-правила — он единственный
источник истины и для передачи, и для определения области offload (через
rsync --list-only). Своего матчера в режиме нет.
Синтаксис правил (gitignore-подобный): * (в пределах сегмента), ** (cross-segment),
?, [abc]/[a-z], завершающий / (только каталоги), ведущий/срединный / (якорь к
корню передачи). exclude исключает объект; include возвращает его (override).
rsync обрабатывает фильтры по принципу «первое совпадение», поэтому порядок:
1. безусловные артефакты: - /.fs-syncher.toml, - /.fs-log.log, - .env
2. include (override): + <pattern>
3. exclude: - <pattern>
Артефакты .fs-syncher.toml, .fs-log.log, .env исключаются всегда и не возвращаются
никаким include (иначе меняющийся .fs-log.log сломал бы идемпотентность). Файл нельзя
вернуть из исключённого целиком каталога — rsync в него не заходит; возвращайте
каталог явно (include = ["dir/"]), затем точечные правила внутри.
- Offload-безопасность: при
after_push∈ {delete,archive} локальный файл убирается только после подтверждённой передачи (verify = trueсверяет повторнымrsync --dry-run --checksum). Сбой передачи отменяетafter_pushцеликом;--dry-runлокальные файлы не трогает; частичный успех не трогает непереданное. - Сохранение якорных include-каталогов: при
[[backup]]опустевшие каталоги, явно заданные якорными include-правилами (например**/Back/), не удаляются после offload. Промежуточные/вложенные каталоги сохраняются только если заданы отдельным якорным include-паттерном (например**/Back/**/Fold/**/). Для одного и того же якоря сохраняется ближайший уровень: при.../Back/BackвнутреннийBackудаляется, если не задан отдельным более точным паттерном.
local_root/remote_root/archive_dir и after_push = "archive" — это внешний
контракт fs-syncher. Внутри режим по-прежнему использует свою модель профиля и
сборку команды rsync; семантика rsync и других библиотек не переопределяется.
- Delete-guard: серверные удаления выше порога (
delete_thresholdпо количеству илиdelete_threshold_pctпо доле) блокируются (код 3) до явного подтверждения (--force-deleteилиforce_delete = true).
| Код | Условие |
|---|---|
| 0 | успех, включая «изменений нет» |
| 1 | ошибка запуска: нет каталога/.fs-syncher.toml, ошибка валидации, нет rsync (или ssh при SSH-цели) |
| 2 | rsync/offload завершились ошибкой (передача неполная) |
| 3 | остановлено delete-guard (превышен порог удаления без подтверждения) |
Итог прогона — наихудший среди профилей по шкале 0 < 2 < 3 (код 1 — ошибка до
запуска профилей). Сбой записи .fs-log.log на код возврата не влияет.
При наихудшем коде прогона 2 или 3 режим шлёт fire-and-forget веб-хук
(POST {"text": ...} с заголовком Authorization: Bearer <токен>, если токен задан;
сетевые ошибки гасятся и на код возврата не влияют). Конфигурация — в едином .env
проекта (FS_TOOLS_HOME/.env, фолбэк — .env в текущем каталоге), шаблон —
.env.example:
FSSYNCHER_WEBHOOK_URL=https://example.com/hook
FSSYNCHER_WEBHOOK_TOKEN=секретный-токенОсобенности: приоритет — переменные окружения процесса важнее значений из .env;
только https:// URL (токен не уходит по нешифрованному каналу). Без FSSYNCHER_WEBHOOK_URL
уведомления отключены; токен необязателен.
from pathlib import Path
from fs_tools.syncher import load_config, build_command, run_rsync
config = load_config(Path("/path/to/dir"))
profile = config.profiles[0]
outcome = run_rsync(build_command(profile, dry_run=True, delete=profile.delete))
print(outcome.sent, outcome.deleted)Все режимы пишут единый журнал .fs-log.log в выбранный каталог (общий формат из
fs_tools.shared.log): блок с меткой времени, строками
Инструмент: normalizer|checker|syncher|schemer, Режим: production|dry-run,
Результат: и строками тела:
- нормализатор — последовательность событий в порядке выполнения:
old -> new,(КОНФЛИКТ) old -> new,(ОШИБКА) old -> new: <текст>либо(изменений нет); - проверка — отсутствующие пути (ошибки сканирования
**-обхода — впереди списка, с пометкой(ОШИБКА)) либо(нарушений нет); - синхронизация — операции с маркерами:
+ <путь>(отправлено/обновлено),- <путь>(удалено на сервере),>> <путь>(выгружено и удалено/архивировано локально), а также(КОНФЛИКТ)/(ОШИБКА)по профилям — тоже в хронологическом порядке; при пустом результате пишется(изменений нет); - проверка схемы — строки нарушений (тип + путь, для контентных — ожидание/факт;
для
read_error— пометка(ОШИБКА)и текст исключения) либо(нарушений нет).
Файл скрыт, создаётся при отсутствии и дополняется при повторных запусках
(намеренное исключение из идемпотентности), добавлен в .gitignore. Журнал пишется
и в production, и в dry-run; во втором случае фиксируется последовательность
dry-run-событий без применения изменений.
В examples/ — четыре песочницы: examples/normalizer/ (фикстуры по
правилам + скрипты отката reset.*), examples/checker/ (дерево с .fs-checker и
намеренно отсутствующими путями), examples/syncher/ (источник + локальные
приёмники, прогон без сети, канонический --dry-run) и examples/schemer/
(.fs-schemer.toml вынесен из Warehouse/ через apply_root, намеренные нарушения
F1–F15). Подробности — в README соответствующих секций.
Для стабильного аудита используй project skill audit-governor с фиксированными
режимами:
audit changed— аудит внесенных правок;audit full— полный аудит проекта.
Рекомендуемые короткие запросы:
Запусти audit changed и доведи до полного green.Запусти audit full и доведи до полного green.
Обязательный цикл проверок после каждой серии правок:
.venv/bin/python -m pytest -q
.venv/bin/python -m pylint --persistent=n --recursive=y src tools tests/*
.venv/bin/python -m ruff check .
.venv/bin/python -m mypy --strict -p fs_toolsЦикл повторяется до полного green по всем четырем командам.
pytest # тесты
pylint --recursive=y src tools tests/* # Pylint (охват src, tools и tests/*)
ruff check . # линтер (в т.ч. порядок импортов, isort)
mypy --strict -p fs_tools # проверка типов
python -m build # сборка sdist + wheelРаскладка тестов зеркалит пакет: tests/shared/, tests/normalizer/, tests/checker/,
tests/syncher/, tests/schemer/ (режим --import-mode=importlib). Код проходит
ruff и mypy --strict без замечаний. Интеграционные тесты режима синхронизации
пропускаются, если в системе нет rsync.
Каталог tools/ — служебный инструментарий репозитория, в дистрибутив он не
входит. В tools/hooks/ лежат форматтеры документации и кода (компактная форма
markdown-таблиц, колонка inline-комментариев, автоправки ruff) вместе с
тестами-барьерами, которые ловят правки в обход форматтеров; их запускает
Claude Code после каждой правки файла, а pytest прогоняет как обычные тесты.
Подробности — tools/hooks/README.md.