конфиг: одна версия и один служебный файл, .av-dev.toml в корне

Версий было две — канон 14 в docs/.docs.json и формат задач 1 в
<каталог задач>/.tasks.json, — и порознь они двигались потому, что плагины
ставились порознь. Плагин один, версия одна и начинается с 1; журналы обеих
прежних нумераций закрыты и лежат рядом непереписанными, действующий журнал
открывается записью о слиянии с перечнем шагов проекту.

Формат TOML взят ради комментариев: файл живёт в репозитории проекта, и
назначение числа читают из него самого. Отсюда правило записи — скрипты правят
строку, а не переписывают файл. Читатель общий, shared/config.py: два разбора
одной схемы были бы двумя домами.

Каталог задач перестал узнаваться служебным файлом и называется ключом
[tasks] dir; узнают его по индексу. Прежние файлы не читаются — увидев их,
docs.py и tasks.py называют прежнюю раскладку и зовут upgrade.
This commit is contained in:
av
2026-08-13 10:30:18 +03:00
parent 6b162c421d
commit 95c9499f06
16 changed files with 1450 additions and 1117 deletions
+228
View File
@@ -0,0 +1,228 @@
#!/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"