#!/usr/bin/env python3 """Служебный файл проекта `.av-dev.toml`: чтение, запись, конверсия старого. **Это дом.** Файл один на весь плагин, поэтому и читатель у него один: `docs.py` и `tasks.py` берут настройки отсюда, а не каждый своим разбором. Два разбора одного формата — это два дома для одной схемы, и расходятся они молча: первым разъезжается не значение ключа, а то, что скрипт делает, ключа не увидев. Формат TOML выбран ради **комментариев**: файл лежит в чужом репозитории, и человек, открывший его через полгода, обязан прочитать в нём, что означает число. JSON комментариев не знает, и объяснение приходилось держать в документации, то есть в другом файле. Читается `tomllib` из стандартной библиотеки (python 3.11+), пишется руками: писателя TOML в стандартной библиотеке нет, а комментарии переживают только построчную правку. Поэтому версия двигается заменой одной строки, а не перезаписью файла — иначе повышение канона стирало бы то, ради чего формат и взят. Схема: version = 1 # версия раскладки av-dev, целое число [docs] migrations = "путь/к/миграциям" # необязателен: есть БД — есть ключ [tasks] dir = "tasks" # каталог задач от корня репозитория items = "items" # имена частей каталога — необязательны backlog = "BACKLOG.md" roadmap = "ROADMAP.md" Коды выхода зовущих скриптов — общий словарь av-dev; отсюда возвращается исключение `ConfigError`, а решает по нему вызывающий. """ from __future__ import annotations import re import tomllib from pathlib import Path # Имя файла называет владельца: раскладку ведёт плагин `av-dev`. До слияния # плагинов файлов было два — `docs/.docs.json` (версия канона) и # `<каталог задач>/.tasks.json` (версия формата задач), и версии двигались # порознь, потому что плагины ставились порознь. Плагин теперь один, версия # одна, и дом у неё в корне репозитория: настройки нужны и проекту без `docs/`, # и проекту без каталога задач, а корень есть у обоих. CONFIG_NAME = ".av-dev.toml" # Прежние дома. Читаются не для работы, а для узнавания: увидели — говорим # «старая раскладка, нужен upgrade», и это одна строка вместо отказа, за которым # человек идёт заводить второй файл рядом с первым. LEGACY = ("docs/.docs.json", "docs/.pm.json") LEGACY_TASKS = ".tasks.json" # Версия раскладки — одна на плагин. Журнал версий — references/changelog.md # скилла `doc-canon`, повышает его операция `upgrade`. VERSION = 1 VERSION_KEY = "version" class ConfigError(Exception): """Файл есть, но прочитать его нельзя: битый TOML или не та схема.""" def find_root(start: Path | None = None) -> Path | None: """Корень проекта: где лежит `.av-dev.toml`, иначе где лежит `.git`. Обе опоры нужны: до `adopt` файла ещё нет, а работать по каталогу задач уже можно. Возвращается None, когда нет ни того, ни другого, — тогда зовущий сам решает, отказ это или неприменимость. """ here = (start or Path.cwd()).resolve() for base in (here, *here.parents): if (base / CONFIG_NAME).is_file(): return base for base in (here, *here.parents): if (base / ".git").exists(): return base return None def read(root: Path) -> dict: """Настройки проекта. Файла нет — пустой словарь, это не ошибка.""" path = root / CONFIG_NAME if not path.is_file(): return {} try: data = tomllib.loads(path.read_text(encoding="utf-8")) except tomllib.TOMLDecodeError as exc: raise ConfigError(f"{CONFIG_NAME} не разбирается как TOML: {exc}") from exc except OSError as exc: raise ConfigError(f"{CONFIG_NAME} не читается: {exc}") from exc _validate(data) return data def _validate(data: dict) -> None: got = data.get(VERSION_KEY) if VERSION_KEY in data and (isinstance(got, bool) or not isinstance(got, int)): raise ConfigError( f"{CONFIG_NAME}: ключ «{VERSION_KEY}» — версия раскладки," f" ожидалось целое число, а не {got!r}" ) for name in ("docs", "tasks"): section = data.get(name) if section is not None and not isinstance(section, dict): raise ConfigError( f"{CONFIG_NAME}: секция [{name}] — ожидалась таблица настроек," f" а не {section!r}" ) def section(cfg: dict, name: str) -> dict: got = cfg.get(name, {}) return got if isinstance(got, dict) else {} def version(cfg: dict) -> int | None: got = cfg.get(VERSION_KEY) return got if isinstance(got, int) and not isinstance(got, bool) else None def legacy_files(root: Path, tasks_dir: Path | None = None) -> list[str]: """Следы прежней раскладки — то, что говорит «проект жил до слияния». Каталог задач передаётся отдельно: до чтения настроек его путь неизвестен, а искать `.tasks.json` по всему дереву значит гадать. """ root = root.resolve() found = [rel for rel in LEGACY if (root / rel).is_file()] for base in filter(None, (tasks_dir, root / "tasks", root / "docs" / "tasks")): # Каталог задач приходит и относительным — таким его печатают в # сообщениях; для сравнения с корнем он обязан быть абсолютным. path = (base if base.is_absolute() else Path.cwd() / base) / LEGACY_TASKS if not path.is_file(): continue path = path.resolve() rel = path.relative_to(root).as_posix() if path.is_relative_to(root) else str(path) if rel not in found: found.append(rel) return found def set_version(root: Path, number: int) -> None: """Двинуть версию, не тронув остального: правится одна строка. Перезапись файла целиком стёрла бы комментарии — то единственное, ради чего формат и выбран. Ключа нет вовсе — строка встаёт первой, до всякой секции: ключ верхнего уровня, попавший под `[docs]`, читался бы как её настройка. """ path = root / CONFIG_NAME text = path.read_text(encoding="utf-8") if path.is_file() else "" pattern = re.compile(rf"(?m)^(\s*{VERSION_KEY}\s*=\s*)(\d+)(.*)$") if pattern.search(text): text = pattern.sub(rf"\g<1>{number}\g<3>", text, count=1) else: text = f"{VERSION_KEY} = {number}\n" + text path.write_text(text, encoding="utf-8") def merge_section(root: Path, name: str, values: dict) -> list[str]: """Дописать ключи в секцию, не тронув остального. Возвращает дописанное. Правка построчная по той же причине, что и у версии: перезапись файла целиком стёрла бы комментарии. Ключ, который в секции уже есть, не трогается вовсе — файл в чужом репозитории правит человек, и затирать его значение своим умолчанием нельзя. """ path = root / CONFIG_NAME lines = path.read_text(encoding="utf-8").splitlines() if path.is_file() else [] header = f"[{name}]" start = next((i for i, ln in enumerate(lines) if ln.strip() == header), None) if start is None: added = [f'{k} = "{v}"' for k, v in values.items()] if not added: return [] block = ([""] if lines and lines[-1].strip() else []) + [header, *added] path.write_text("\n".join([*lines, *block]) + "\n", encoding="utf-8") return list(values) end = next((i for i in range(start + 1, len(lines)) if lines[i].lstrip().startswith("[")), len(lines)) body = lines[start + 1:end] have = {ln.split("=", 1)[0].strip() for ln in body if "=" in ln and not ln.lstrip().startswith("#")} added = [k for k in values if k not in have] if not added: return [] insert = [f'{k} = "{values[k]}"' for k in added] while body and not body[-1].strip(): body.pop() lines[start + 1:end] = [*body, *insert] path.write_text("\n".join(lines) + "\n", encoding="utf-8") return added def skeleton(number: int, docs: dict | None = None, tasks: dict | None = None) -> str: """Свежий файл с комментариями — тем, ради чего взят TOML. Пустая секция пишется всё равно: строка «ключа нет, потому что БД нет» читается как решение, а её отсутствие — как недосмотр. """ docs, tasks = docs or {}, tasks or {} out = [ "# Раскладка av-dev в этом проекте: версия и настройки проверок.", "# Файл ведут скиллы плагина, править руками можно — комментарии свои.", "", f"{VERSION_KEY} = {number}" " # версия раскладки; обратной совместимости нет, есть «приведён» и «нет»", "", "[docs]", ] if docs.get("migrations"): out += [ "# каталог миграций: по нему docs.py сверяет схему с database.md", f'migrations = "{docs["migrations"]}"', ] else: out += ["# migrations = \"путь/к/миграциям\" — появится, когда появится БД"] out += ["", "[tasks]", "# каталог задач от корня репозитория; имена частей — умолчания скрипта", f'dir = "{tasks.get("dir", "tasks")}"'] for key in ("items", "backlog", "roadmap"): if tasks.get(key): out.append(f'{key} = "{tasks[key]}"') return "\n".join(out) + "\n"