Files
dev-skills/av-dev/shared/config.py
T
av 95c9499f06 конфиг: одна версия и один служебный файл, .av-dev.toml в корне
Версий было две — канон 14 в docs/.docs.json и формат задач 1 в
<каталог задач>/.tasks.json, — и порознь они двигались потому, что плагины
ставились порознь. Плагин один, версия одна и начинается с 1; журналы обеих
прежних нумераций закрыты и лежат рядом непереписанными, действующий журнал
открывается записью о слиянии с перечнем шагов проекту.

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

Каталог задач перестал узнаваться служебным файлом и называется ключом
[tasks] dir; узнают его по индексу. Прежние файлы не читаются — увидев их,
docs.py и tasks.py называют прежнюю раскладку и зовут upgrade.
2026-08-13 10:30:18 +03:00

229 lines
12 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, когда нет ни того, ни другого, — тогда зовущий сам
решает, отказ это или неприменимость.
"""
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"