конфиг: одна версия и один служебный файл, .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:
@@ -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"
|
||||
Reference in New Issue
Block a user