#!/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" # каталог задач от корня репозитория stage = "build" # стадия проекта: build | support items = "items" # имена частей каталога — необязательны backlog = "BACKLOG.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 # скилла `canon`, повышает его операция `upgrade`. VERSION = 5 VERSION_KEY = "version" class ConfigError(Exception): """Файл есть, но прочитать его нельзя: битый TOML или не та схема.""" def find_root(start: Path | None = None) -> Path | None: """Корень проекта: где лежит `.av-dev.toml`, иначе где лежит `.git`. Обе опоры нужны: до `adopt` файла ещё нет, а работать по каталогу задач уже можно. Возвращается None, когда нет ни того, ни другого, — тогда зовущий сам решает, отказ это или неприменимость. **Подъём останавливается на первом `.git`, и это не деталь.** Репозиторий внутри репозитория — обычное дело, и без границы конфиг соседа выигрывал бы у собственного: вложенный проект объявлялся бы здоровым по чужому файлу, а запись настроек уходила бы в чужой репозиторий. Свой файл ищется **до** границы включительно, чужой не ищется вовсе. """ here = (start or Path.cwd()).resolve() for base in (here, *here.parents): if (base / CONFIG_NAME).is_file(): return base if (base / ".git").exists(): return base # корень репозитория есть, настроек в нём нет return None def read(root: Path) -> dict: """Настройки проекта. Файла нет — пустой словарь, это не ошибка.""" path = root / CONFIG_NAME if not path.is_file(): return {} try: # Читаем байтами: `tomllib.load` сам знает про кодировку TOML, а # `read_text` на файле не в UTF-8 роняет UnicodeDecodeError — ошибку # окружения, которая ушла бы наружу внутренним сбоем. with path.open("rb") as fh: data = tomllib.load(fh) except tomllib.TOMLDecodeError as exc: raise ConfigError(f"{CONFIG_NAME} не разбирается как TOML: {exc}") from exc except (OSError, ValueError) as exc: raise ConfigError(f"{CONFIG_NAME} не читается: {exc}") from exc _validate(data) return data # Ключи верхнего уровня. Секции знают свои ключи сами: `[docs]` проверяет # `docs.py`, `[tasks]` — `tasks.py`. Здесь только то, что образует сам файл. TOP_KEYS = (VERSION_KEY, "docs", "tasks") def check_keys(data: dict, known: tuple[str, ...], where: str) -> None: """Неизвестный ключ — отказ, а не безмолвный пропуск. Ключ, положенный не туда (`migrations` верхним уровнем вместо `[docs]` — ровно так он лежал в прежнем `.docs.json`, и ровно так его перенесут руками), иначе не значит ничего: проверка объявляет себя неприменимой, отчёт выходит зелёным, и на месте настройки оказывается тишина. """ unknown = sorted(set(data) - set(known)) if unknown: raise ConfigError( f"{CONFIG_NAME}: неизвестные ключи {where}: {', '.join(unknown)}" f" (известны: {', '.join(known)})" ) 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"): got_section = data.get(name) if got_section is not None and not isinstance(got_section, dict): raise ConfigError( f"{CONFIG_NAME}: секция [{name}] — ожидалась таблица настроек," f" а не {got_section!r}" ) check_keys(data, TOP_KEYS, "верхнего уровня") 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 quote(value: str) -> str: """Значение как строка TOML: экранирование, а не конкатенация в кавычки. Без него имя файла с кавычкой или путь с обратной косой чертой ломают **весь** файл: `tomllib` отказывается разбирать его целиком, и оба скрипта после этого отвечают кодом 3 на любую команду. Пишет сюда машина, а последствия достаются человеку, который такого имени не выбирал. """ out = value.replace("\\", "\\\\").replace('"', '\\"') out = out.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t") return f'"{out}"' def _strip_comment(line: str) -> str: """Строка без хвостового комментария. Кавычки уважаются: `#` внутри них — текст.""" quoted = False for i, ch in enumerate(line): if ch == '"' and (i == 0 or line[i - 1] != "\\"): quoted = not quoted elif ch == "#" and not quoted: return line[:i] return line def _is_header(line: str, name: str | None = None) -> bool: """Заголовок секции — по разбору, а не по совпадению строки. `[tasks] # имена частей` — законный TOML и ровно та возможность, ради которой формат и взят. Сравнение строк её не узнаёт, дописывает вторую таблицу с тем же именем, и `tomllib` отвергает файл целиком. """ body = _strip_comment(line).strip() if not (body.startswith("[") and body.endswith("]")): return False return name is None or body[1:-1].strip() == name def set_version(root: Path, number: int) -> None: """Двинуть версию, не тронув остального: правится одна строка. Перезапись файла целиком стёрла бы комментарии — то единственное, ради чего формат и выбран. **Ищется только ключ верхнего уровня** — то есть выше первого заголовка секции. `version` внутри `[docs]` принадлежит проекту и значит что угодно своё; двинув его, мы объявили бы приведённым не то, о чём речь, и оставили бы настоящую версию неназванной. Ключа нет вовсе — строка встаёт первой, до всякой секции, по той же причине. """ path = root / CONFIG_NAME lines = path.read_text(encoding="utf-8").splitlines() if path.is_file() else [] end = next((i for i, ln in enumerate(lines) if _is_header(ln)), len(lines)) # Значение берётся до комментария и может быть каким угодно — в том числе # строкой в кавычках: файл правят руками. Заменяется оно целиком, иначе # рядом появился бы второй ключ `version`, и файл перестал бы разбираться. pattern = re.compile(rf"^(\s*{VERSION_KEY}\s*=\s*)([^#]*?)(\s*(?:#.*)?)$") for i in range(end): match = pattern.match(lines[i]) if match: lines[i] = f"{match.group(1)}{number}{match.group(3)}" break else: lines.insert(0, f"{VERSION_KEY} = {number}") path.write_text("\n".join(lines) + "\n", 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 [] start = next((i for i, ln in enumerate(lines) if _is_header(ln, name)), None) if start is None: if not values: return [] block = ([""] if lines and lines[-1].strip() else []) + [f"[{name}]"] block += [f"{k} = {quote(v)}" for k, v in values.items()] 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 _is_header(lines[i])), len(lines)) body = lines[start + 1:end] have = {ln.split("=", 1)[0].strip() for ln in map(_strip_comment, body) if "=" in ln} added = [k for k in values if k not in have] if not added: return [] # Пустые строки в хвосте секции — отбивка перед следующим заголовком. # Дописываем до неё, а её возвращаем на место: иначе файл слипается. trailing = 0 while body and not body[-1].strip(): body.pop() trailing += 1 insert = [f"{k} = {quote(values[k])}" for k in added] lines[start + 1:end] = [*body, *insert, *([""] * trailing)] path.write_text("\n".join(lines) + "\n", encoding="utf-8") return added def set_section_key(root: Path, name: str, key: str, value: str) -> None: """Заменить значение ключа секции, не тронув остального. Отличается от `merge_section` ровно тем, ради чего и заведена: та **не трогает** ключ, который уже есть, потому что дописывает умолчания в чужой файл. Здесь же значение меняет команда, которую позвал человек, и не переписать его значило бы промолчать о выполненном действии. Ключа нет — он дописывается, секции нет — заводится: и то и другое законное состояние файла, который правят руками. """ path = root / CONFIG_NAME lines = path.read_text(encoding="utf-8").splitlines() if path.is_file() else [] start = next((i for i, ln in enumerate(lines) if _is_header(ln, name)), None) if start is None: merge_section(root, name, {key: value}) return end = next((i for i in range(start + 1, len(lines)) if _is_header(lines[i])), len(lines)) pattern = re.compile(rf"^(\s*{re.escape(key)}\s*=\s*)([^#]*?)(\s*(?:#.*)?)$") for i in range(start + 1, end): if (match := pattern.match(lines[i])): lines[i] = f"{match.group(1)}{quote(value)}{match.group(3)}" path.write_text("\n".join(lines) + "\n", encoding="utf-8") return merge_section(root, name, {key: value}) def missing_keys(root: Path, name: str, values: dict) -> dict: """Ключи секции, которые уже есть и разошлись с тем, что мы собирались дать. `merge_section` чужого значения не трогает — и правильно делает, — но промолчать о расхождении нельзя: `dir` из настроек и `--dir` из вызова, разойдясь, оставляют каталог, до которого потом не дотянется никто. """ have = section(read(root), name) return {k: have[k] for k, v in values.items() if k in have and have[k] != v} 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 = {quote(docs['migrations'])}", ] else: out += ['# migrations = "путь/к/миграциям" — появится, когда появится БД'] out += ["", "[tasks]", "# каталог задач от корня репозитория; имена частей — умолчания скрипта", f"dir = {quote(tasks.get('dir', 'tasks'))}"] if tasks.get("stage"): out += ["# стадия проекта: build — беклог это план стройки, порядок строк" " значит зависимость;", "# support — беклог это очередь правок, порядок значит важность", f"stage = {quote(tasks['stage'])}"] for key in ("items", "backlog", "rejected"): if tasks.get(key): out.append(f"{key} = {quote(tasks[key])}") return "\n".join(out) + "\n"