Files
dev-skills/av-dev/shared/config.py
T
av 441469d78d вычитка ревью: пережитки трёх плагинов и язык слияния
Восемнадцать веток «плагина нет» описывали недостижимое: скиллы и агенты теперь
в одном плагине и разрешаются всегда. Где предмет всё же может отсутствовать —
ветка переписана на след в проекте (нет docs/, нет каталога задач, нет
openspec/); где отсутствовать нечему — снята. Туда же анонсы, обещавшие ветку,
которой в разделе больше нет.

Правило копий и его применение разъезжались в одном коммите: правило называло
два законных случая, а absence.md разослан семью копиями по SKILL.md. Назван
третий случай, и разрез проверяемый — файл, который модель получает целиком,
против файла, за которым она идёт отдельным чтением. Заодно сняты объявления
копий там, где копию сменила ссылка, и довод у карты домов в doc-consistency:
он ссылался на отсутствие плагина, хотя устав едет вместе с плагином.

Описания скиллов во фронтматтерах звали снятые короткие имена — по ним скилл не
находится. task-track перестал обещать повышение: версию двигает doc-canon.

Язык: сняты кросс-вызов, опцион и деградация, конверсия и «читатель» в
config.py, charter'ы против уставов, замер против подсчёта, страдательный залог
в журнале. Строка «настройки av-dev» в таблице отсутствия — слово «раскладка»
называло и целое, и его часть.

Мелкое: тема 52 в README была 64, транслит в task-wording машина не проверяет,
мёртвая ветка REQUIRED в addresses.py, ссылки на язык в закрытом журнале.
2026-08-13 11:03:11 +03:00

324 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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, когда нет ни того, ни другого, — тогда зовущий сам
решает, отказ это или неприменимость.
**Подъём останавливается на первом `.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 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'))}"]
for key in ("items", "backlog", "roadmap"):
if tasks.get(key):
out.append(f"{key} = {quote(tasks[key])}")
return "\n".join(out) + "\n"